diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index abd19bdad..eef11e32a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ env: # 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 feat/events-hourly-rollup fix/analytics-tz-bucket-offset + STAGING_DEPLOY_BRANCH_ALLOWLIST: "" jobs: checks: diff --git a/.gitignore b/.gitignore index 2e352dcf6..45970660f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ supabase.sh tests/ !server/tests !packages/mcp/tests +!packages/ai-sdk/tests !apps/leaf/tests !vite/tests .secrets diff --git a/apps/docs/api-reference-generator/balances/trackTokens.mdx b/apps/docs/api-reference-generator/balances/trackTokens.mdx new file mode 100644 index 000000000..fa947801c --- /dev/null +++ b/apps/docs/api-reference-generator/balances/trackTokens.mdx @@ -0,0 +1,78 @@ +--- +title: "Track Token Usage" +openapi: "openapi POST /v1/balances.track_tokens" +--- + +import { DynamicParamField } from "/snippets/dynamic-param-field.jsx"; +import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx"; +import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + + Track AI token usage against a customer's AI credit system balance. Converts token counts to a dollar cost using [Models.dev](https://models.dev) pricing and your configured markup, then deducts from the customer's credit balance. + + +The `model_id` must use `provider/model` format, matching the provider and model keys from [Models.dev](https://models.dev). For providers with nested model paths (like OpenRouter), include the full path: `openrouter/anthropic/claude-opus-4.6`. The first path segment is the provider key used for `providerMarkups` lookup. + +### Common Use Cases + + + +```typescript Anthropic +await autumn.balances.trackTokens({ + customerId: "cus_123", + modelId: "anthropic/claude-opus-4-6", + inputTokens: 1000, + outputTokens: 500 +}); +``` + +```typescript With cache + reasoning +await autumn.balances.trackTokens({ + customerId: "cus_123", + modelId: "anthropic/claude-opus-4-6", + inputTokens: 800, // excludes the cached tokens below + outputTokens: 350, // excludes the reasoning tokens below + cacheReadTokens: 1000, + cacheWriteTokens: 200, + reasoningTokens: 150 +}); +``` + +```typescript OpenRouter (nested path) +await autumn.balances.trackTokens({ + customerId: "cus_123", + modelId: "openrouter/anthropic/claude-opus-4-6", + inputTokens: 2000, + outputTokens: 1000 +}); +``` + +```typescript With explicit feature +await autumn.balances.trackTokens({ + customerId: "cus_123", + featureId: "ai_credits", + modelId: "anthropic/claude-haiku-4-5", + inputTokens: 2000, + outputTokens: 1000 +}); +``` + + + +### Token Pools + +Each token parameter is an exclusive pool — no token should be counted in more than one. `input_tokens` is non-cached text input only (cached tokens go in `cache_read_tokens` / `cache_write_tokens`), and `output_tokens` is text output only (reasoning tokens go in `reasoning_tokens`, audio in `audio_input_tokens` / `audio_output_tokens`). Each pool is billed at the model's published rate for that pool, falling back to the text input/output rate when the model has none. + + + If you pass a provider's raw totals (e.g. OpenAI's `prompt_tokens` and `completion_tokens`), subtract the cache and reasoning counts first — otherwise those tokens are billed twice. The [`@useautumn/ai-sdk` wrapper](/documentation/external-providers/ai-sdk) does this normalization for you. + + +### Markup Resolution + +Markups are optional — the credit system's default markup applies unless overridden per provider or per model. With no markups set, the Models.dev base cost is charged as-is. A markup of `-100` makes the model free — the usage event is still recorded, but nothing is deducted. See [AI Credit Systems](/documentation/modelling-pricing/credit-systems#ai-credit-systems) for configuration. + +The recorded event's `properties` include the full pricing breakdown: `cost`, `base_cost`, `markup`, `markup_source` (`model`, `provider`, or `default`), `tier_applied` (whether large-context tier pricing applied), and the per-pool `rates` used. + + + `feature_id` is auto-detected when the customer has exactly one AI credit system. The request fails if the customer has none, or has more than one and `feature_id` is omitted. + diff --git a/apps/docs/mintlify/api-reference/balances/trackTokens.mdx b/apps/docs/mintlify/api-reference/balances/trackTokens.mdx new file mode 100644 index 000000000..41894bbdc --- /dev/null +++ b/apps/docs/mintlify/api-reference/balances/trackTokens.mdx @@ -0,0 +1,206 @@ +--- +title: "Track Token Usage" +openapi: "openapi POST /v1/balances.track_tokens" +--- + +import { DynamicParamField } from "/snippets/dynamic-param-field.jsx"; +import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx"; +import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + + Track AI token usage against a customer's AI credit system balance. Converts token counts to a dollar cost using [Models.dev](https://models.dev) pricing and your configured markup, then deducts from the customer's credit balance. + + +The `model_id` must use `provider/model` format, matching the provider and model keys from [Models.dev](https://models.dev). For providers with nested model paths (like OpenRouter), include the full path: `openrouter/anthropic/claude-opus-4.6`. The first path segment is the provider key used for provider-level markup lookup. + +### Common Use Cases + + + +```typescript Anthropic +await autumn.balances.trackTokens({ + customerId: "cus_123", + modelId: "anthropic/claude-opus-4-6", + inputTokens: 1000, + outputTokens: 500 +}); +``` + +```typescript With cache + reasoning +await autumn.balances.trackTokens({ + customerId: "cus_123", + modelId: "anthropic/claude-opus-4-6", + inputTokens: 800, // excludes the cached tokens below + outputTokens: 350, // excludes the reasoning tokens below + cacheReadTokens: 1000, + cacheWriteTokens: 200, + reasoningTokens: 150 +}); +``` + +```typescript OpenRouter (nested path) +await autumn.balances.trackTokens({ + customerId: "cus_123", + modelId: "openrouter/anthropic/claude-opus-4.6", + inputTokens: 2000, + outputTokens: 1000 +}); +``` + +```typescript With explicit feature +await autumn.balances.trackTokens({ + customerId: "cus_123", + featureId: "ai_credits", + modelId: "anthropic/claude-haiku-4-5", + inputTokens: 2000, + outputTokens: 1000 +}); +``` + + + +### Token Pools + +Each token parameter is an exclusive pool — no token should be counted in more than one. Each pool is billed at the model's published rate for that pool, falling back to the text input/output rate when the model has none. + + + If you pass a provider's raw totals (e.g. OpenAI's `prompt_tokens` and `completion_tokens`), subtract the cache and reasoning counts first — otherwise those tokens are billed twice. The [`@useautumn/ai-sdk` wrapper](/documentation/external-providers/ai-sdk) does this normalization for you. + + +### Markup Resolution + +Markups are optional — the credit system's default markup applies unless overridden per provider or per model. With no markups set, the Models.dev base cost is charged as-is. A markup of `-100` makes the model free — the usage event is still recorded, but nothing is deducted. See [AI Credit Systems](/documentation/modelling-pricing/credit-systems#ai-credit-systems) for configuration. + + + `feature_id` is auto-detected when the customer has exactly one AI credit system. The request fails if the customer has none, or has more than one and `feature_id` is omitted. + + +### Body Parameters + + + The ID of the customer. + + + + The AI model in `provider/model` format, matching keys from [Models.dev](https://models.dev) (e.g., `anthropic/claude-opus-4-6`, `openai/gpt-4o`, `openrouter/anthropic/claude-opus-4.6`). + + + + Number of non-cached text input tokens consumed. Exclusive of the cache and audio token pools. + + + + Number of text output tokens consumed. Exclusive of the reasoning and audio output pools. + + + + Number of cached input tokens read, billed at the model's cache read rate. + + + + Number of input tokens written to the cache, billed at the model's cache write rate. + + + + Number of reasoning tokens generated, billed at the model's reasoning rate (falls back to the output rate). + + + + Number of audio input tokens consumed, billed at the model's audio input rate (falls back to the input rate). + + + + Number of audio output tokens generated, billed at the model's audio output rate (falls back to the output rate). + + + + The ID of the AI credit system feature. If omitted, automatically detects the customer's AI credit system feature. Required when the customer has more than one. + + + + The ID of the entity for entity-scoped balances. + + + + Additional properties to attach to this usage event. The token counts and a pricing breakdown (`cost`, `base_cost`, `markup`, `markup_source`, `tier_applied`, `rates`) are automatically included. + + +### Response + + + The ID of the customer whose token usage was tracked. + + + + The dollar cost that was deducted from the customer's AI credit balance. + + + + The updated balance for the AI credit system feature. + + + The feature ID this balance is for. + + + + Total balance granted (included + prepaid). + + + + Remaining balance available for use. + + + + Total usage consumed in the current period. + + + + Whether this feature has unlimited usage. + + + + Whether usage beyond the granted balance is allowed. + + + + Timestamp when the balance will reset, or null for no reset. + + + + + + + +```json 200 +{ + "customer_id": "cus_123", + "value": 0.06, + "balance": { + "feature_id": "ai_credits", + "granted": 10.00, + "remaining": 9.94, + "usage": 0.06, + "unlimited": false, + "overage_allowed": false, + "next_reset_at": 1773851121437, + "breakdown": [ + { + "id": "cus_ent_abc123", + "plan_id": "pro_plan", + "included_grant": 10.00, + "prepaid_grant": 0, + "remaining": 9.94, + "usage": 0.06, + "unlimited": false, + "reset": { + "interval": "month", + "resets_at": 1773851121437 + }, + "price": null, + "expires_at": null + } + ] + } +} +``` + diff --git a/apps/docs/mintlify/api-reference/billing/attach.mdx b/apps/docs/mintlify/api-reference/billing/attach.mdx index 00910d02e..6e4eee44b 100644 --- a/apps/docs/mintlify/api-reference/billing/attach.mdx +++ b/apps/docs/mintlify/api-reference/billing/attach.mdx @@ -132,7 +132,7 @@ This is useful for attaching custom metadata to the Stripe subscription created - Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items. + Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items. The ID of the feature to configure. @@ -197,8 +197,8 @@ This is useful for attaching custom metadata to the Stripe subscription created 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. - - 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. @@ -309,8 +309,8 @@ This is useful for attaching custom metadata to the Stripe subscription created 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. - - 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. @@ -367,7 +367,11 @@ This is useful for attaching custom metadata to the Stripe subscription created - Match items with this interval. + 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. + + + + Match items with this interval_count. Disambiguates between items that share an interval but differ in count. diff --git a/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx b/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx index a6f4eca75..428968e3d 100644 --- a/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx +++ b/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx @@ -99,7 +99,7 @@ const response = await autumn.billing.update({ - Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items. + Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items. The ID of the feature to configure. @@ -164,8 +164,8 @@ const response = await autumn.billing.update({ 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. - - 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. @@ -276,8 +276,8 @@ const response = await autumn.billing.update({ 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. - - 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. @@ -334,7 +334,11 @@ const response = await autumn.billing.update({ - Match items with this interval. + 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. + + + + Match items with this interval_count. Disambiguates between items that share an interval but differ in count. diff --git a/apps/docs/mintlify/api-reference/billing/createSchedule.mdx b/apps/docs/mintlify/api-reference/billing/createSchedule.mdx index 2e3554a43..e60596e5a 100644 --- a/apps/docs/mintlify/api-reference/billing/createSchedule.mdx +++ b/apps/docs/mintlify/api-reference/billing/createSchedule.mdx @@ -118,7 +118,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Customize the plan to schedule. Can override the price, items, or both. + Customize the plan to schedule. Can override price, replace items, or patch items with add_items, remove_items, and update_items. Base price configuration for a plan. @@ -139,7 +139,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Override the items in the plan. + Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items. The ID of the feature to configure. @@ -204,8 +204,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. - - 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. @@ -250,6 +250,140 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Items to add to the plan. + + + The ID of the feature to configure. + + + + Number of free units included. Balance resets to this each interval for consumable features. + + + + If true, customer has unlimited access to this feature. + + + + Reset configuration for consumable features. Omit for non-consumable features like seats. + + + Interval at which balance resets (e.g. 'month', 'year'). For consumable features only. + + + + Number of intervals between resets. Defaults to 1. + + + + + + + Pricing for usage beyond included units. Omit for free features. + + + Price per billing_units after included usage. Either 'amount' or 'tiers' is required. + + + + Tiered pricing. Either 'amount' or 'tiers' is required. + + + + + + + + + + + + + + Billing interval. For consumable features, should match reset.interval. + + + + Number of intervals per billing cycle. Defaults to 1. + + + + Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200). + + + + 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. + + + + Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. + + + + + + + Proration settings for prepaid features. Controls mid-cycle quantity change billing. + + + Billing behavior when quantity increases mid-cycle. + + + + Credit behavior when quantity decreases mid-cycle. + + + + + + + Rollover config for unused units. If set, unused included units carry over. + + + Max rollover units. Omit for unlimited rollover. + + + + Maximum rollover as a percentage (0-100) of included + prepaid grant. Mutually exclusive with max. + + + + When rolled over units expire. + + + + Number of periods before expiry. + + + + + + + + + + Filters selecting items to remove from the plan. + + + Match items linked to this feature. + + + + Match items with this billing method (prepaid or usage_based). + + + + 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. + + + + Match items with this interval_count. Disambiguates between items that share an interval but differ in count. + + + + + diff --git a/apps/docs/mintlify/api-reference/billing/multiAttach.mdx b/apps/docs/mintlify/api-reference/billing/multiAttach.mdx index e96a497a7..eb30f5fed 100644 --- a/apps/docs/mintlify/api-reference/billing/multiAttach.mdx +++ b/apps/docs/mintlify/api-reference/billing/multiAttach.mdx @@ -111,8 +111,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. - - 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. diff --git a/apps/docs/mintlify/api-reference/billing/previewAttach.mdx b/apps/docs/mintlify/api-reference/billing/previewAttach.mdx index 87fc7abde..2c7a49dad 100644 --- a/apps/docs/mintlify/api-reference/billing/previewAttach.mdx +++ b/apps/docs/mintlify/api-reference/billing/previewAttach.mdx @@ -65,7 +65,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items. + Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items. The ID of the feature to configure. @@ -130,8 +130,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. - - 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. @@ -242,8 +242,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. - - 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. @@ -300,7 +300,11 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Match items with this interval. + 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. + + + + Match items with this interval_count. Disambiguates between items that share an interval but differ in count. @@ -757,7 +761,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -825,8 +829,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -1086,7 +1098,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -1154,8 +1166,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + diff --git a/apps/docs/mintlify/api-reference/billing/previewMultiAttach.mdx b/apps/docs/mintlify/api-reference/billing/previewMultiAttach.mdx index a3ae73470..b8afcc482 100644 --- a/apps/docs/mintlify/api-reference/billing/previewMultiAttach.mdx +++ b/apps/docs/mintlify/api-reference/billing/previewMultiAttach.mdx @@ -111,8 +111,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. - - 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. @@ -788,7 +788,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -856,8 +856,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -1117,7 +1125,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -1185,8 +1193,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + diff --git a/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx b/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx index c42cc86d4..ac05c2b50 100644 --- a/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx +++ b/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx @@ -65,7 +65,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items. + Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items. The ID of the feature to configure. @@ -130,8 +130,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. - - 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. @@ -242,8 +242,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. - - 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. @@ -300,7 +300,11 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Match items with this interval. + 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. + + + + Match items with this interval_count. Disambiguates between items that share an interval but differ in count. @@ -690,7 +694,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -758,8 +762,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -1019,7 +1031,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -1087,8 +1099,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + diff --git a/apps/docs/mintlify/api-reference/billing/setupPayment.mdx b/apps/docs/mintlify/api-reference/billing/setupPayment.mdx index 097cf567b..70b27618b 100644 --- a/apps/docs/mintlify/api-reference/billing/setupPayment.mdx +++ b/apps/docs/mintlify/api-reference/billing/setupPayment.mdx @@ -65,7 +65,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items. + Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items. The ID of the feature to configure. @@ -130,8 +130,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. - - 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. @@ -242,8 +242,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. - - 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. @@ -300,7 +300,11 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Match items with this interval. + 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. + + + + Match items with this interval_count. Disambiguates between items that share an interval but differ in count. diff --git a/apps/docs/mintlify/api-reference/core/check.mdx b/apps/docs/mintlify/api-reference/core/check.mdx index c6f8628ac..8c783389d 100644 --- a/apps/docs/mintlify/api-reference/core/check.mdx +++ b/apps/docs/mintlify/api-reference/core/check.mdx @@ -124,8 +124,8 @@ const { allowed } = await autumn.check({ Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -150,6 +150,30 @@ const { allowed } = await autumn.check({ + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. @@ -255,8 +279,16 @@ const { allowed } = await autumn.check({ The per-unit price amount. - + Tiered pricing configuration if applicable. + + + + + + + + @@ -320,8 +352,8 @@ const { allowed } = await autumn.check({ Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -346,6 +378,30 @@ const { allowed } = await autumn.check({ + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. @@ -451,8 +507,16 @@ const { allowed } = await autumn.check({ The per-unit price amount. - + Tiered pricing configuration if applicable. + + + + + + + + @@ -528,8 +592,8 @@ const { allowed } = await autumn.check({ Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -554,6 +618,30 @@ const { allowed } = await autumn.check({ + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. diff --git a/apps/docs/mintlify/api-reference/core/track.mdx b/apps/docs/mintlify/api-reference/core/track.mdx index 29fe56afa..2d10fe804 100644 --- a/apps/docs/mintlify/api-reference/core/track.mdx +++ b/apps/docs/mintlify/api-reference/core/track.mdx @@ -135,8 +135,8 @@ await autumn.track({ Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -161,6 +161,30 @@ await autumn.track({ + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. @@ -266,8 +290,16 @@ await autumn.track({ The per-unit price amount. - + Tiered pricing configuration if applicable. + + + + + + + + @@ -331,8 +363,8 @@ await autumn.track({ Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -357,6 +389,30 @@ await autumn.track({ + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. @@ -462,8 +518,16 @@ await autumn.track({ The per-unit price amount. - + Tiered pricing configuration if applicable. + + + + + + + + diff --git a/apps/docs/mintlify/api-reference/core/trackTokens.mdx b/apps/docs/mintlify/api-reference/core/trackTokens.mdx new file mode 100644 index 000000000..4dd31df42 --- /dev/null +++ b/apps/docs/mintlify/api-reference/core/trackTokens.mdx @@ -0,0 +1,622 @@ +--- +title: "Track Tokens" +openapi: "openapi POST /v1/balances.track_tokens" +--- + +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 + + + The ID of the customer. + + + + The ID of the entity for entity-scoped balances. + + + + The ID of the AI credit system feature. Auto-detected from the customer's entitlements if omitted — only required when a customer has multiple AI credit systems. + + + + The AI model as '/' (e.g. 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). The provider is the first path segment and must match a provider + model key in models.dev. + + + + Number of non-cached text input tokens consumed. Exclusive of cache and audio token pools. + + + + Number of text output tokens consumed. Exclusive of the reasoning and audio output pools. + + + + Number of cached input tokens read. + + + + Number of input tokens written to the cache. + + + + Number of audio input tokens consumed. + + + + Number of audio output tokens generated. + + + + Number of reasoning tokens generated. + + + + Additional properties to attach to this usage event. + + + +### Response + + + The ID of the customer whose usage was tracked. + + + + The ID of the entity, if entity-scoped tracking was performed. + + + + The event name that was tracked, if event_name was used instead of feature_id. + + + + The amount of usage that was recorded. + + + + The updated balance for the tracked feature. Null if tracking by event_name that affects multiple features. + + + The feature ID this balance is for. + + + + The full feature object if expanded. + + + The unique identifier for this feature, used in /check and /track calls. + + + + Human-readable name displayed in the dashboard and billing UI. + + + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. + + + + For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage). + + + + Event names that trigger this feature's balance. Allows multiple features to respond to a single event. + + + + For credit_system features: maps metered features to their credit costs. + + + ID of the metered feature that draws from this credit system. + + + + Credits consumed per unit of the metered feature. + + + + + + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + + + Display names for the feature in billing UI and customer-facing components. + + + Singular form for UI display (e.g., 'API call', 'seat'). + + + + Plural form for UI display (e.g., 'API calls', 'seats'). + + + + + + + Whether the feature is archived and hidden from the dashboard. + + + + + + + Total balance granted (included + prepaid). + + + + Remaining balance available for use. + + + + Total usage consumed in the current period. + + + + Whether this feature has unlimited usage. + + + + Whether usage beyond the granted balance is allowed (with overage charges). + + + + Maximum quantity that can be purchased as a top-up, or null for unlimited. + + + + Timestamp when the balance will reset, or null for no reset. + + + + Detailed breakdown of balance sources when stacking multiple plans or grants. + + + The unique identifier for this balance breakdown. + + + + The plan ID this balance originates from, or null for standalone balances. + + + + Amount granted from the plan's included usage. + + + + Amount granted from prepaid purchases or top-ups. + + + + Remaining balance available for use. + + + + Amount consumed in the current period. + + + + Whether this balance has unlimited usage. + + + + Reset configuration for this balance, or null if no reset. + + + The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals. + + + + Number of intervals between resets (eg. 2 for bi-monthly). + + + + Timestamp when the balance will next reset. + + + + + + + Pricing configuration if this balance has usage-based pricing. + + + The per-unit price amount. + + + + Tiered pricing configuration if applicable. + + + + + + + + + + + + How tiers are applied: graduated (split across bands) or volume (flat rate for the matched tier). + + + + The number of units per billing increment (eg. $9 / 250 units). + + + + Whether usage is prepaid or billed pay-per-use. + + + + Maximum quantity that can be purchased, or null for unlimited. + + + + + + + Timestamp when this balance expires, or null for no expiration. + + + + + + + Rollover balances carried over from previous periods. + + + Amount of balance rolled over from a previous period. + + + + Timestamp when the rollover balance expires. + + + + + + + + + + Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature. + + + The feature ID this balance is for. + + + + The full feature object if expanded. + + + The unique identifier for this feature, used in /check and /track calls. + + + + Human-readable name displayed in the dashboard and billing UI. + + + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. + + + + For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage). + + + + Event names that trigger this feature's balance. Allows multiple features to respond to a single event. + + + + For credit_system features: maps metered features to their credit costs. + + + ID of the metered feature that draws from this credit system. + + + + Credits consumed per unit of the metered feature. + + + + + + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + + + Display names for the feature in billing UI and customer-facing components. + + + Singular form for UI display (e.g., 'API call', 'seat'). + + + + Plural form for UI display (e.g., 'API calls', 'seats'). + + + + + + + Whether the feature is archived and hidden from the dashboard. + + + + + + + Total balance granted (included + prepaid). + + + + Remaining balance available for use. + + + + Total usage consumed in the current period. + + + + Whether this feature has unlimited usage. + + + + Whether usage beyond the granted balance is allowed (with overage charges). + + + + Maximum quantity that can be purchased as a top-up, or null for unlimited. + + + + Timestamp when the balance will reset, or null for no reset. + + + + Detailed breakdown of balance sources when stacking multiple plans or grants. + + + The unique identifier for this balance breakdown. + + + + The plan ID this balance originates from, or null for standalone balances. + + + + Amount granted from the plan's included usage. + + + + Amount granted from prepaid purchases or top-ups. + + + + Remaining balance available for use. + + + + Amount consumed in the current period. + + + + Whether this balance has unlimited usage. + + + + Reset configuration for this balance, or null if no reset. + + + The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals. + + + + Number of intervals between resets (eg. 2 for bi-monthly). + + + + Timestamp when the balance will next reset. + + + + + + + Pricing configuration if this balance has usage-based pricing. + + + The per-unit price amount. + + + + Tiered pricing configuration if applicable. + + + + + + + + + + + + How tiers are applied: graduated (split across bands) or volume (flat rate for the matched tier). + + + + The number of units per billing increment (eg. $9 / 250 units). + + + + Whether usage is prepaid or billed pay-per-use. + + + + Maximum quantity that can be purchased, or null for unlimited. + + + + + + + Timestamp when this balance expires, or null for no expiration. + + + + + + + Rollover balances carried over from previous periods. + + + Amount of balance rolled over from a previous period. + + + + Timestamp when the rollover balance expires. + + + + + + + + + + Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling. + + + ID of the underlying balance row that was deducted from (customer_entitlement or rollover). + + + + The feature this balance belongs to. + + + + ID of the plan/product this balance belongs to. Null when the balance can't be attributed to a single plan (e.g. it spans multiple). + + + + Reset configuration for the balance this deduction came from, or null if the balance doesn't reset. + + + The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals. + + + + Number of intervals between resets (eg. 2 for bi-monthly). + + + + Timestamp when the balance will next reset. + + + + + + + Amount deducted from this balance. Positive when usage was consumed, negative when credit was restored (e.g. a refund via negative track value). + + + + + + + +```json 200 +{ + "customer_id": "cus_123", + "value": 0.006, + "balance": { + "feature_id": "ai_credits", + "granted": 10, + "remaining": 9.994, + "usage": 0.006, + "unlimited": false, + "overage_allowed": false, + "max_purchase": null, + "next_reset_at": 1773851121437, + "breakdown": [ + { + "id": "cus_ent_39qmLooixXLAqMywgXywjAz96rV", + "plan_id": "pro_plan", + "included_grant": 100, + "prepaid_grant": 0, + "remaining": 72, + "usage": 28, + "unlimited": false, + "reset": { + "interval": "month", + "resets_at": 1773851121437 + }, + "price": null, + "expires_at": null + } + ] + }, + "deductions": [ + { + "balance_id": "cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2", + "feature_id": "ai_credits", + "plan_id": "pro", + "reset": { + "interval": "month", + "resets_at": 1781288736881 + }, + "value": 0.006 + } + ] +} +``` + diff --git a/apps/docs/mintlify/api-reference/customers/getCustomer.mdx b/apps/docs/mintlify/api-reference/customers/getCustomer.mdx index 3f6235e26..19810365a 100644 --- a/apps/docs/mintlify/api-reference/customers/getCustomer.mdx +++ b/apps/docs/mintlify/api-reference/customers/getCustomer.mdx @@ -252,7 +252,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -320,8 +320,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -603,7 +611,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -671,8 +679,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -856,8 +872,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -882,6 +898,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. @@ -987,8 +1027,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The per-unit price amount. - + Tiered pricing configuration if applicable. + + + + + + + + @@ -1064,8 +1112,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -1090,6 +1138,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. diff --git a/apps/docs/mintlify/api-reference/customers/getOrCreateCustomer.mdx b/apps/docs/mintlify/api-reference/customers/getOrCreateCustomer.mdx index 54592ceea..82fc8beda 100644 --- a/apps/docs/mintlify/api-reference/customers/getOrCreateCustomer.mdx +++ b/apps/docs/mintlify/api-reference/customers/getOrCreateCustomer.mdx @@ -406,7 +406,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -474,8 +474,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -757,7 +765,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -825,8 +833,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -1010,8 +1026,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -1036,6 +1052,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. @@ -1141,8 +1181,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The per-unit price amount. - + Tiered pricing configuration if applicable. + + + + + + + + @@ -1218,8 +1266,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -1244,6 +1292,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. diff --git a/apps/docs/mintlify/api-reference/customers/listCustomers.mdx b/apps/docs/mintlify/api-reference/customers/listCustomers.mdx index 9236a6afa..95e0d89e9 100644 --- a/apps/docs/mintlify/api-reference/customers/listCustomers.mdx +++ b/apps/docs/mintlify/api-reference/customers/listCustomers.mdx @@ -277,7 +277,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -345,8 +345,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -628,7 +636,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -696,8 +704,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -881,8 +897,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -907,6 +923,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. @@ -1012,8 +1052,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The per-unit price amount. - + Tiered pricing configuration if applicable. + + + + + + + + @@ -1089,8 +1137,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -1115,6 +1163,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. diff --git a/apps/docs/mintlify/api-reference/customers/updateCustomer.mdx b/apps/docs/mintlify/api-reference/customers/updateCustomer.mdx index a4541e8c9..cff9e69ba 100644 --- a/apps/docs/mintlify/api-reference/customers/updateCustomer.mdx +++ b/apps/docs/mintlify/api-reference/customers/updateCustomer.mdx @@ -394,7 +394,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -462,8 +462,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -745,7 +753,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -813,8 +821,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -998,8 +1014,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -1024,6 +1040,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. @@ -1129,8 +1169,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The per-unit price amount. - + Tiered pricing configuration if applicable. + + + + + + + + @@ -1206,8 +1254,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -1232,6 +1280,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. diff --git a/apps/docs/mintlify/api-reference/entities/createEntity.mdx b/apps/docs/mintlify/api-reference/entities/createEntity.mdx index adae7ab69..1309eed80 100644 --- a/apps/docs/mintlify/api-reference/entities/createEntity.mdx +++ b/apps/docs/mintlify/api-reference/entities/createEntity.mdx @@ -359,7 +359,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -427,8 +427,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -709,7 +717,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -777,8 +785,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -961,8 +977,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -987,6 +1003,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. @@ -1092,8 +1132,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The per-unit price amount. - + Tiered pricing configuration if applicable. + + + + + + + + @@ -1168,8 +1216,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -1194,6 +1242,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. diff --git a/apps/docs/mintlify/api-reference/entities/getEntity.mdx b/apps/docs/mintlify/api-reference/entities/getEntity.mdx index e06e7e00c..782c91d5e 100644 --- a/apps/docs/mintlify/api-reference/entities/getEntity.mdx +++ b/apps/docs/mintlify/api-reference/entities/getEntity.mdx @@ -131,7 +131,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -199,8 +199,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -481,7 +489,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -549,8 +557,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -733,8 +749,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -759,6 +775,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. @@ -864,8 +904,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The per-unit price amount. - + Tiered pricing configuration if applicable. + + + + + + + + @@ -940,8 +988,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -966,6 +1014,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. diff --git a/apps/docs/mintlify/api-reference/entities/listEntities.mdx b/apps/docs/mintlify/api-reference/entities/listEntities.mdx index 445b15b79..14c2c4871 100644 --- a/apps/docs/mintlify/api-reference/entities/listEntities.mdx +++ b/apps/docs/mintlify/api-reference/entities/listEntities.mdx @@ -160,7 +160,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -228,8 +228,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -510,7 +518,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -578,8 +586,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -762,8 +778,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -788,6 +804,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. @@ -893,8 +933,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The per-unit price amount. - + Tiered pricing configuration if applicable. + + + + + + + + @@ -969,8 +1017,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -995,6 +1043,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. diff --git a/apps/docs/mintlify/api-reference/entities/updateEntity.mdx b/apps/docs/mintlify/api-reference/entities/updateEntity.mdx index e447dfcc8..80688b68c 100644 --- a/apps/docs/mintlify/api-reference/entities/updateEntity.mdx +++ b/apps/docs/mintlify/api-reference/entities/updateEntity.mdx @@ -195,7 +195,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -263,8 +263,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -545,7 +553,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature @@ -613,8 +621,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + @@ -797,8 +813,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -823,6 +839,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. @@ -928,8 +968,16 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The per-unit price amount. - + Tiered pricing configuration if applicable. + + + + + + + + @@ -1004,8 +1052,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -1030,6 +1078,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. diff --git a/apps/docs/mintlify/api-reference/features/createFeature.mdx b/apps/docs/mintlify/api-reference/features/createFeature.mdx index c5e886377..3e90cb904 100644 --- a/apps/docs/mintlify/api-reference/features/createFeature.mdx +++ b/apps/docs/mintlify/api-reference/features/createFeature.mdx @@ -13,7 +13,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. @@ -32,7 +32,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. + A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. @@ -41,6 +41,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. + + + + + + + + + + + + Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. + + + + + + @@ -58,8 +82,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -84,6 +108,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. diff --git a/apps/docs/mintlify/api-reference/features/getFeature.mdx b/apps/docs/mintlify/api-reference/features/getFeature.mdx index 613525244..e10037e8e 100644 --- a/apps/docs/mintlify/api-reference/features/getFeature.mdx +++ b/apps/docs/mintlify/api-reference/features/getFeature.mdx @@ -24,8 +24,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -50,6 +50,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. diff --git a/apps/docs/mintlify/api-reference/features/listFeatures.mdx b/apps/docs/mintlify/api-reference/features/listFeatures.mdx index 9a79453b2..24d4f122c 100644 --- a/apps/docs/mintlify/api-reference/features/listFeatures.mdx +++ b/apps/docs/mintlify/api-reference/features/listFeatures.mdx @@ -20,8 +20,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -46,6 +46,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. diff --git a/apps/docs/mintlify/api-reference/features/updateFeature.mdx b/apps/docs/mintlify/api-reference/features/updateFeature.mdx index e81e347ba..e3b74885b 100644 --- a/apps/docs/mintlify/api-reference/features/updateFeature.mdx +++ b/apps/docs/mintlify/api-reference/features/updateFeature.mdx @@ -13,7 +13,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; The name of the feature. - + The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. @@ -32,7 +32,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. + A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. @@ -41,6 +41,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. + + + + + + + + + + + + Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. + + + + + + @@ -66,8 +90,8 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Human-readable name displayed in the dashboard and billing UI. - - Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + + Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. @@ -92,6 +116,30 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + + Per-model markup overrides for AI credit systems. + + + + + + + + + + + + Default percentage markup for AI credit systems. Use -100 to make usage free. + + + + Per-provider default markup percentages for AI credit systems. + + + + + + Display names for the feature in billing UI and customer-facing components. diff --git a/apps/docs/mintlify/api-reference/plans/createPlan.mdx b/apps/docs/mintlify/api-reference/plans/createPlan.mdx index a58a9e7d9..d2b9faede 100644 --- a/apps/docs/mintlify/api-reference/plans/createPlan.mdx +++ b/apps/docs/mintlify/api-reference/plans/createPlan.mdx @@ -248,8 +248,8 @@ await autumn.plans.create({ 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. - - 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. @@ -409,7 +409,7 @@ await autumn.plans.create({ The name of the feature. - + The type of the feature @@ -477,8 +477,16 @@ await autumn.plans.create({ Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + diff --git a/apps/docs/mintlify/api-reference/plans/getPlan.mdx b/apps/docs/mintlify/api-reference/plans/getPlan.mdx index 2f349ede5..72871a513 100644 --- a/apps/docs/mintlify/api-reference/plans/getPlan.mdx +++ b/apps/docs/mintlify/api-reference/plans/getPlan.mdx @@ -119,7 +119,7 @@ const plan = await autumn.plans.get({ The name of the feature. - + The type of the feature @@ -187,8 +187,16 @@ const plan = await autumn.plans.get({ Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + diff --git a/apps/docs/mintlify/api-reference/plans/listPlans.mdx b/apps/docs/mintlify/api-reference/plans/listPlans.mdx index a05a22c9e..73993c662 100644 --- a/apps/docs/mintlify/api-reference/plans/listPlans.mdx +++ b/apps/docs/mintlify/api-reference/plans/listPlans.mdx @@ -136,7 +136,7 @@ const plans = await autumn.plans.list({ The name of the feature. - + The type of the feature @@ -204,8 +204,16 @@ const plans = await autumn.plans.list({ Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + diff --git a/apps/docs/mintlify/api-reference/plans/updatePlan.mdx b/apps/docs/mintlify/api-reference/plans/updatePlan.mdx index 44a66273c..aebb14fac 100644 --- a/apps/docs/mintlify/api-reference/plans/updatePlan.mdx +++ b/apps/docs/mintlify/api-reference/plans/updatePlan.mdx @@ -175,8 +175,8 @@ await autumn.plans.update({ 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. - - 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. @@ -263,6 +263,8 @@ await autumn.plans.update({ The new ID to use for the plan. Can only be updated if the plan has not been used by any customers. + + ### Response @@ -344,7 +346,7 @@ await autumn.plans.update({ The name of the feature. - + The type of the feature @@ -412,8 +414,16 @@ await autumn.plans.update({ Price per billing_units after included usage is consumed. Mutually exclusive with tiers. - + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + diff --git a/apps/docs/mintlify/api-reference/webhooks/billingUpdated.mdx b/apps/docs/mintlify/api-reference/webhooks/billingUpdated.mdx index abcfe1099..305500add 100644 --- a/apps/docs/mintlify/api-reference/webhooks/billingUpdated.mdx +++ b/apps/docs/mintlify/api-reference/webhooks/billingUpdated.mdx @@ -97,6 +97,168 @@ openapi: "api/openapi.yml webhook billing.updated" The ID of the feature that was added or removed. + + The item snapshot that was added or removed. + + + The ID of the feature this item configures. + + + + The full feature object if expanded. + + + The ID of the feature, used to refer to it in other API calls like /track or /check. + + + + The name of the feature. + + + + The type of the feature + + + + Singular and plural display names for the feature. + + + The singular display name for the feature. + + + + The plural display name for the feature. + + + + + + + Credit cost schema for credit system features. + + + The ID of the metered feature (should be a single_use feature). + + + + The credit cost of the metered feature. + + + + + + + Whether or not the feature is archived. + + + + + + + Number of free units included. For consumable features, balance resets to this number each interval. + + + + Whether the customer has unlimited access to this feature. + + + + Reset configuration for consumable features. Null for non-consumable features like seats where usage persists across billing cycles. + + + The interval at which the feature balance resets (e.g. 'month', 'year'). For consumable features, usage resets to 0 and included units are restored. + + + + Number of intervals between resets. Defaults to 1. + + + + + + + Pricing configuration for usage beyond included units. Null if feature is entirely free. + + + Price per billing_units after included usage is consumed. Mutually exclusive with tiers. + + + + Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + + + + + + + + + + + + + + Billing interval for this price. For consumable features, should match reset.interval. + + + + Number of intervals per billing cycle. Defaults to 1. + + + + Number of units per price increment. Usage is rounded UP to the nearest billing_units when billed (e.g. billing_units=100 means 101 usage rounds to 200). + + + + 'prepaid' for features like seats where customers pay upfront, 'usage_based' for pay-as-you-go after included usage. + + + + Maximum units a customer can purchase beyond included. E.g. if included=100 and max_purchase=300, customer can use up to 400 total before usage is capped. Null for no limit. + + + + + + + Display text for showing this item in pricing pages. + + + Main display text (e.g. '$10' or '100 messages'). + + + + Secondary display text (e.g. 'per month' or 'then $0.5 per 100'). + + + + + + + Rollover configuration for unused units. If set, unused included units roll over to the next period. + + + Maximum rollover units. Null for unlimited rollover. + + + + Maximum rollover as a percentage (0-100) of included + prepaid grant. Mutually exclusive with max. + + + + When rolled over units expire. + + + + Number of periods before expiry. + + + + + + + + diff --git a/apps/docs/mintlify/api/openapi.yml b/apps/docs/mintlify/api/openapi.yml index 683707a8b..515467a90 100644 --- a/apps/docs/mintlify/api/openapi.yml +++ b/apps/docs/mintlify/api/openapi.yml @@ -586,10 +586,11 @@ components: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit - pools." + pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -617,6 +618,45 @@ components: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -1089,6 +1129,7 @@ components: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -1178,9 +1219,19 @@ components: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. @@ -1410,10 +1461,11 @@ components: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit - pools." + pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -1441,6 +1493,45 @@ components: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -1567,9 +1658,19 @@ components: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration if applicable. tier_behavior: enum: @@ -2353,10 +2454,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -2384,6 +2487,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -3247,10 +3389,13 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' - for unified credit pools." + for unified credit pools, + 'ai_credit_system' for model-based token + pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -3278,6 +3423,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -4119,10 +4303,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -4150,6 +4336,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -4574,9 +4799,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -4825,6 +5053,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -4914,9 +5143,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. @@ -5364,6 +5603,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -5453,9 +5693,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. @@ -5881,6 +6131,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -5971,9 +6222,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. @@ -6427,9 +6688,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -6547,6 +6811,8 @@ paths: pattern: ^[a-zA-Z0-9_-]+$ description: The new ID to use for the plan. Can only be updated if the plan has not been used by any customers. + disable_version: + type: boolean required: - plan_id title: UpdatePlanParams @@ -6664,6 +6930,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -6753,9 +7020,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. @@ -7154,6 +7431,7 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features @@ -7190,8 +7468,51 @@ paths: required: - metered_feature_id - credit_cost - description: A schema that maps 'single_use' feature IDs to credit costs. - Applicable only for 'credit_system' features. + description: A schema that maps 'single_use' feature IDs to credit costs. For + classic credit systems only — AI credit systems use + model_markups instead. + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. Maps model IDs to + their markup configuration. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for this AI credit system. Used when no + model or provider markup applies. Use -100 to make usage + free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. + Provider keys match the first segment of model_id. event_names: type: array items: @@ -7240,10 +7561,11 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit - pools." + pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -7271,6 +7593,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -7382,10 +7743,11 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit - pools." + pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -7413,6 +7775,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -7506,10 +7907,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified - credit pools." + credit pools, 'ai_credit_system' for model-based + token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -7537,6 +7940,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -7635,6 +8077,7 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features @@ -7671,8 +8114,51 @@ paths: required: - metered_feature_id - credit_cost - description: A schema that maps 'single_use' feature IDs to credit costs. - Applicable only for 'credit_system' features. + description: A schema that maps 'single_use' feature IDs to credit costs. For + classic credit systems only — AI credit systems use + model_markups instead. + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. Maps model IDs to + their markup configuration. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for this AI credit system. Used when no + model or provider markup applies. Use -100 to make usage + free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. + Provider keys match the first segment of model_id. event_names: type: array items: @@ -7721,10 +8207,11 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit - pools." + pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -7752,6 +8239,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -8067,9 +8593,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -8129,7 +8658,7 @@ paths: pricing, and rollover settings. description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items - / update_items. + / deprecated update_items. add_items: type: array items: @@ -8225,9 +8754,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -8301,15 +8833,37 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). @@ -8924,10 +9478,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 - total. + total. Null for no limit. required: - interval - billing_method @@ -8986,10 +9542,219 @@ paths: title: PlanItem description: Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. - description: Override the items in the plan. - additionalProperties: false - description: Customize the plan to schedule. Can override the price, items, or - both. + 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: + type: array + items: + type: object + properties: + feature_id: + type: string + description: The ID of the feature to configure. + included: + type: number + description: Number of free units included. Balance resets to this each interval + for consumable features. + unlimited: + type: boolean + description: If true, customer has unlimited access to this feature. + reset: + type: object + properties: + interval: + enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + description: Interval at which balance resets (e.g. 'month', 'year'). For + consumable features only. + interval_count: + type: number + description: Number of intervals between resets. Defaults to 1. + required: + - interval + description: Reset configuration for consumable features. Omit for + non-consumable features like seats. + price: + type: object + properties: + amount: + type: number + description: Price per billing_units after included usage. Either 'amount' or + 'tiers' is required. + tiers: + type: array + items: + type: object + properties: + to: {} + amount: {} + flat_amount: {} + description: Tiered pricing. Either 'amount' or 'tiers' is required. + tier_behavior: + enum: + - graduated + - volume + type: string + interval: + enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + description: Billing interval. For consumable features, should match + reset.interval. + interval_count: + type: number + default: 1 + description: Number of intervals per billing cycle. Defaults to 1. + billing_units: + type: number + default: 1 + description: Units per price increment. Usage is rounded UP when billed (e.g. + billing_units=100 means 101 + rounds to 200). + billing_method: + enum: + - prepaid + - usage_based + type: string + description: "'prepaid' for upfront payment (seats), 'usage_based' for + pay-as-you-go." + max_purchase: + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, + max_purchase=300 allows 400 + total. Null for no limit. + required: + - interval + - billing_method + description: Pricing for usage beyond included units. Omit for free features. + proration: + type: object + properties: + on_increase: + enum: + - bill_immediately + - prorate_immediately + - prorate_next_cycle + - bill_next_cycle + type: string + description: Billing behavior when quantity increases mid-cycle. + on_decrease: + enum: + - prorate + - prorate_immediately + - prorate_next_cycle + - none + - no_prorations + type: string + description: Credit behavior when quantity decreases mid-cycle. + required: + - on_increase + - on_decrease + description: Proration settings for prepaid features. Controls mid-cycle + quantity change billing. + rollover: + type: object + properties: + max: + type: number + description: Max rollover units. Omit for unlimited rollover. + max_percentage: + type: number + description: Maximum rollover as a percentage (0-100) of included + prepaid + grant. Mutually exclusive with + max. + expiry_duration_type: + enum: + - month + - forever + type: string + description: When rolled over units expire. + expiry_duration_length: + type: number + description: Number of periods before expiry. + required: + - expiry_duration_type + description: Rollover config for unused units. If set, unused included units + carry over. + required: + - feature_id + title: PlanItem + description: Configuration for a feature item in a plan, including usage limits, + pricing, and rollover settings. + description: Items to add to the plan. + remove_items: + type: array + items: + type: object + properties: + feature_id: + type: string + description: Match items linked to this feature. + billing_method: + enum: + - prepaid + - usage_based + type: string + description: Match items with this billing method (prepaid or usage_based). + interval: + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in + count. + title: PlanItemFilter + description: Filter for matching plan items. All provided fields must match + (AND). + description: Filters selecting items to remove from the plan. + description: Customize the plan to schedule. Can override price, replace items, + or patch items with add_items, remove_items, + and update_items. subscription_id: type: string description: A unique ID to identify this subscription. Useful when scheduling @@ -9158,9 +9923,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + Null for no limit. required: - interval - billing_method @@ -9219,10 +9987,218 @@ paths: title: PlanItem description: Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. - description: Override the items in the plan. - additionalProperties: false - description: Customize the plan to schedule. Can override the price, items, or - both. + 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: + type: array + items: + type: object + properties: + feature_id: + type: string + description: The ID of the feature to configure. + included: + type: number + description: Number of free units included. Balance resets to this each interval + for consumable features. + unlimited: + type: boolean + description: If true, customer has unlimited access to this feature. + reset: + type: object + properties: + interval: + enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + description: Interval at which balance resets (e.g. 'month', 'year'). For + consumable features only. + interval_count: + type: number + description: Number of intervals between resets. Defaults to 1. + required: + - interval + description: Reset configuration for consumable features. Omit for + non-consumable features like seats. + price: + type: object + properties: + amount: + type: number + description: Price per billing_units after included usage. Either 'amount' or + 'tiers' is required. + tiers: + type: array + items: + type: object + properties: + to: {} + amount: {} + flat_amount: {} + description: Tiered pricing. Either 'amount' or 'tiers' is required. + tier_behavior: + enum: + - graduated + - volume + type: string + interval: + enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + description: Billing interval. For consumable features, should match + reset.interval. + interval_count: + type: number + default: 1 + description: Number of intervals per billing cycle. Defaults to 1. + billing_units: + type: number + default: 1 + description: Units per price increment. Usage is rounded UP when billed (e.g. + billing_units=100 means 101 rounds + to 200). + billing_method: + enum: + - prepaid + - usage_based + type: string + description: "'prepaid' for upfront payment (seats), 'usage_based' for + pay-as-you-go." + max_purchase: + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, + max_purchase=300 allows 400 total. + Null for no limit. + required: + - interval + - billing_method + description: Pricing for usage beyond included units. Omit for free features. + proration: + type: object + properties: + on_increase: + enum: + - bill_immediately + - prorate_immediately + - prorate_next_cycle + - bill_next_cycle + type: string + description: Billing behavior when quantity increases mid-cycle. + on_decrease: + enum: + - prorate + - prorate_immediately + - prorate_next_cycle + - none + - no_prorations + type: string + description: Credit behavior when quantity decreases mid-cycle. + required: + - on_increase + - on_decrease + description: Proration settings for prepaid features. Controls mid-cycle + quantity change billing. + rollover: + type: object + properties: + max: + type: number + description: Max rollover units. Omit for unlimited rollover. + max_percentage: + type: number + description: Maximum rollover as a percentage (0-100) of included + prepaid + grant. Mutually exclusive with + max. + expiry_duration_type: + enum: + - month + - forever + type: string + description: When rolled over units expire. + expiry_duration_length: + type: number + description: Number of periods before expiry. + required: + - expiry_duration_type + description: Rollover config for unused units. If set, unused included units + carry over. + required: + - feature_id + title: PlanItem + description: Configuration for a feature item in a plan, including usage limits, + pricing, and rollover settings. + description: Items to add to the plan. + remove_items: + type: array + items: + type: object + properties: + feature_id: + type: string + description: Match items linked to this feature. + billing_method: + enum: + - prepaid + - usage_based + type: string + description: Match items with this billing method (prepaid or usage_based). + interval: + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in + count. + title: PlanItemFilter + description: Filter for matching plan items. All provided fields must match + (AND). + description: Filters selecting items to remove from the plan. + description: Customize the plan to schedule. Can override price, replace items, + or patch items with add_items, remove_items, and + update_items. subscription_id: type: string description: A unique ID to identify this subscription. Useful when scheduling @@ -9602,9 +10578,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null + for no limit. required: - interval - billing_method @@ -10223,9 +11202,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -10285,7 +11267,7 @@ paths: pricing, and rollover settings. description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items - / update_items. + / deprecated update_items. add_items: type: array items: @@ -10381,9 +11363,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -10457,15 +11442,37 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). @@ -11297,9 +12304,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null + for no limit. required: - interval - billing_method @@ -12243,9 +13253,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -12305,7 +13318,7 @@ paths: pricing, and rollover settings. description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items - / update_items. + / deprecated update_items. add_items: type: array items: @@ -12401,9 +13414,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -12477,15 +13493,37 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). @@ -12940,9 +13978,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -13002,7 +14043,7 @@ paths: pricing, and rollover settings. description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items - / update_items. + / deprecated update_items. add_items: type: array items: @@ -13098,9 +14139,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -13174,15 +14218,37 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). @@ -14033,9 +15099,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -14095,7 +15164,7 @@ paths: pricing, and rollover settings. description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items - / update_items. + / deprecated update_items. add_items: type: array items: @@ -14191,9 +15260,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -14267,15 +15339,37 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). @@ -15106,10 +16200,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -15137,6 +16233,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -15655,10 +16790,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -15686,6 +16823,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -16547,6 +17723,422 @@ paths: feature_id="messages", value=1, ) + /v1/balances.track_tokens: + post: + operationId: trackTokens + description: >- + Records AI token usage for a customer and returns the updated AI credit + balance. + + + Use this after an LLM request when you have input and output token + counts. Autumn converts token usage to a dollar amount using the + configured model pricing and markup, then tracks that value against the + customer's AI credit system. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + customer_id: + type: string + description: The ID of the customer. + entity_id: + type: string + description: The ID of the entity for entity-scoped balances. + feature_id: + type: string + description: The ID of the AI credit system feature. Auto-detected from the + customer's entitlements if omitted — only required when a + customer has multiple AI credit systems. + model_id: + type: string + description: The AI model as '/' (e.g. + 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). + The provider is the first path segment and must match a + provider + model key in models.dev. + input_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of non-cached text input tokens consumed. Exclusive of cache + and audio token pools. + output_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of text output tokens consumed. Exclusive of the reasoning + and audio output pools. + cache_read_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of cached input tokens read. + cache_write_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of input tokens written to the cache. + audio_input_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of audio input tokens consumed. + audio_output_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of audio output tokens generated. + reasoning_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of reasoning tokens generated. + properties: + type: object + propertyNames: + type: string + additionalProperties: {} + description: Additional properties to attach to this usage event. + required: + - customer_id + - model_id + - input_tokens + - output_tokens + title: TrackTokensParams + examples: + - &a57 + customer_id: cus_123 + feature_id: ai_credits + model_id: anthropic/claude-sonnet-4-20250514 + input_tokens: 1000 + output_tokens: 500 + example: *a57 + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + customer_id: + type: string + description: The ID of the customer whose usage was tracked. + entity_id: + type: string + description: The ID of the entity, if entity-scoped tracking was performed. + event_name: + type: string + description: The event name that was tracked, if event_name was used instead of + feature_id. + value: + type: number + description: The amount of usage that was recorded. + balance: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: The updated balance for the tracked feature. Null if tracking by + event_name that affects multiple features. + balances: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: Map of feature_id to updated balance for the tracked feature and + any related features (e.g. linked credit systems). Value + is null when the customer has no balance for that feature. + deductions: + type: array + items: + type: object + properties: + balance_id: + type: string + description: ID of the underlying balance row that was deducted from + (customer_entitlement or rollover). + feature_id: + type: string + description: The feature this balance belongs to. + plan_id: + anyOf: + - type: string + - type: "null" + description: ID of the plan/product this balance belongs to. Null when the + balance can't be attributed to a single plan (e.g. + it spans multiple). + reset: + anyOf: + - type: object + properties: + interval: + anyOf: + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + - const: multiple + description: The reset interval (hour, day, week, month, etc.) or 'multiple' if + combined from different intervals. + interval_count: + type: number + description: Number of intervals between resets (eg. 2 for bi-monthly). + resets_at: + anyOf: + - type: number + - type: "null" + description: Timestamp when the balance will next reset. + required: + - interval + - resets_at + - type: "null" + description: Reset configuration for the balance this deduction came from, or + null if the balance doesn't reset. + value: + type: number + description: Amount deducted from this balance. Positive when usage was + consumed, negative when credit was restored (e.g. a + refund via negative track value). + required: + - balance_id + - feature_id + - plan_id + - reset + - value + description: Per-balance breakdown of what this event deducted. A single event + can consume from multiple balance rows when credit systems + or rollovers are involved; this surfaces each one so + callers can build per-feature usage views without polling. + required: + - customer_id + - value + - balance + examples: + - &a58 + customer_id: cus_123 + value: 0.006 + balance: + feature_id: ai_credits + granted: 10 + remaining: 9.994 + usage: 0.006 + unlimited: false + overage_allowed: false + max_purchase: null + next_reset_at: 1773851121437 + breakdown: + - id: cus_ent_39qmLooixXLAqMywgXywjAz96rV + plan_id: pro_plan + included_grant: 100 + prepaid_grant: 0 + remaining: 72 + usage: 28 + unlimited: false + reset: + interval: month + resets_at: 1773851121437 + price: null + expires_at: null + deductions: + - balance_id: cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2 + feature_id: ai_credits + plan_id: pro + reset: + interval: month + resets_at: 1781288736881 + value: 0.006 + example: *a58 + "202": + description: Accepted. Autumn is experiencing degraded service from a downstream + provider, so the token usage event was accepted for replay and will + be tracked as soon as the service is restored. + content: + application/json: + schema: + type: object + properties: + customer_id: + type: string + description: The ID of the customer whose usage was tracked. + entity_id: + type: string + description: The ID of the entity, if entity-scoped tracking was performed. + event_name: + type: string + description: The event name that was tracked, if event_name was used instead of + feature_id. + value: + type: number + description: The amount of usage that was recorded. + balance: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: The updated balance for the tracked feature. Null if tracking by + event_name that affects multiple features. + balances: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: Map of feature_id to updated balance for the tracked feature and + any related features (e.g. linked credit systems). Value + is null when the customer has no balance for that feature. + deductions: + type: array + items: + type: object + properties: + balance_id: + type: string + description: ID of the underlying balance row that was deducted from + (customer_entitlement or rollover). + feature_id: + type: string + description: The feature this balance belongs to. + plan_id: + anyOf: + - type: string + - type: "null" + description: ID of the plan/product this balance belongs to. Null when the + balance can't be attributed to a single plan (e.g. + it spans multiple). + reset: + anyOf: + - type: object + properties: + interval: + anyOf: + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + - const: multiple + description: The reset interval (hour, day, week, month, etc.) or 'multiple' if + combined from different intervals. + interval_count: + type: number + description: Number of intervals between resets (eg. 2 for bi-monthly). + resets_at: + anyOf: + - type: number + - type: "null" + description: Timestamp when the balance will next reset. + required: + - interval + - resets_at + - type: "null" + description: Reset configuration for the balance this deduction came from, or + null if the balance doesn't reset. + value: + type: number + description: Amount deducted from this balance. Positive when usage was + consumed, negative when credit was restored (e.g. a + refund via negative track value). + required: + - balance_id + - feature_id + - plan_id + - reset + - value + description: Per-balance breakdown of what this event deducted. A single event + can consume from multiple balance rows when credit systems + or rollovers are involved; this surfaces each one so + callers can build per-feature usage views without polling. + required: + - customer_id + - value + - balance + examples: + - &a59 + customer_id: cus_123 + value: 0.006 + balance: + feature_id: ai_credits + granted: 10 + remaining: 9.994 + usage: 0.006 + unlimited: false + overage_allowed: false + max_purchase: null + next_reset_at: 1773851121437 + breakdown: + - id: cus_ent_39qmLooixXLAqMywgXywjAz96rV + plan_id: pro_plan + included_grant: 100 + prepaid_grant: 0 + remaining: 72 + usage: 28 + unlimited: false + reset: + interval: month + resets_at: 1773851121437 + price: null + expires_at: null + deductions: + - balance_id: cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2 + feature_id: ai_credits + plan_id: pro + reset: + interval: month + resets_at: 1781288736881 + value: 0.006 + example: *a59 + x-speakeasy-name-override: trackTokens + parameters: + - *a5 + x-codeSamples: + - lang: typescript + label: Typescript (SDK) + source: |- + import { Autumn } from 'autumn-js' + + const autumn = new Autumn() + + const result = await autumn.trackTokens({ + customerId: "cus_123", + featureId: "ai_credits", + modelId: "anthropic/claude-sonnet-4-20250514", + inputTokens: 1000, + outputTokens: 500, + }); + - lang: python + label: Python (SDK) + source: |- + from autumn_sdk import Autumn + + autumn = Autumn(secret_key="am_sk_test...") + + res = autumn.track_tokens( + customer_id="cus_123", + model_id="anthropic/claude-sonnet-4-20250514", + input_tokens=1000, + output_tokens=500, + feature_id="ai_credits", + ) /v1/balances.batch_track: post: operationId: batchTrack @@ -16623,14 +18215,14 @@ paths: - customer_id title: BatchTrackParams examples: - - &a57 + - &a60 - customer_id: cus_123 feature_id: messages value: 1 - customer_id: cus_123 event_name: message.sent value: 1 - example: *a57 + example: *a60 responses: "202": description: "Batch accepted. All items passed synchronous validation. Enqueue @@ -16648,9 +18240,9 @@ paths: required: - success examples: - - &a58 + - &a61 success: true - example: *a58 + example: *a61 x-speakeasy-name-override: batchTrack parameters: - *a5 @@ -16749,7 +18341,7 @@ paths: description: Filter events by time range title: EventsListParams examples: - - &a59 + - &a62 start_cursor: "" customer_id: cus_123 limit: 50 @@ -16758,7 +18350,7 @@ paths: custom_range: start: 1704067200000 end: 1706745600000 - example: *a59 + example: *a62 responses: "200": description: OK @@ -16883,7 +18475,7 @@ paths: - list - next_cursor examples: - - &a60 + - &a63 list: - id: evt_36xpk2TmuQX5zVPPQ8tCtnR5Weg timestamp: 1765958215459 @@ -16907,7 +18499,7 @@ paths: properties: {} deductions: null next_cursor: eyJ2IjowLCJpZCI6ImV2dF8zNnhtSHh4akFrcXh1ZkRmOXlIQVBOZlJyTE0iLCJ0IjoxNzY1OTU2NTEyMDU3fQ - example: *a60 + example: *a63 x-speakeasy-name-override: list parameters: - *a5 @@ -17023,7 +18615,7 @@ paths: - feature_id title: EventsAggregateParams examples: - - &a61 + - &a64 customer_id: cus_123 feature_id: api_calls range: 30d @@ -17034,7 +18626,7 @@ paths: - messages range: 7d group_by: properties.model - example: *a61 + example: *a64 responses: "200": description: OK @@ -17096,7 +18688,7 @@ paths: - list - total examples: - - &a62 + - &a65 list: - period: 1762905600000 values: @@ -17143,7 +18735,7 @@ paths: sessions: count: 2 sum: 15 - example: *a62 + example: *a65 x-speakeasy-name-override: aggregate parameters: - *a5 @@ -17289,12 +18881,12 @@ paths: - entity_id title: CreateEntityParams examples: - - &a63 + - &a66 customer_id: cus_123 entity_id: seat_42 feature_id: seats name: Seat 42 - example: *a63 + example: *a66 responses: "200": description: OK @@ -17496,10 +19088,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -17527,6 +19121,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -17690,7 +19323,7 @@ paths: - balances - flags examples: - - &a64 + - &a67 id: seat_42 name: Seat 42 customer_id: cus_123 @@ -17735,7 +19368,7 @@ paths: price: null expires_at: null invoices: [] - example: *a64 + example: *a67 x-speakeasy-name-override: create parameters: - *a5 @@ -17794,11 +19427,11 @@ paths: - entity_id title: GetEntityParams examples: - - &a65 + - &a68 entity_id: seat_42 - customer_id: cus_123 entity_id: seat_42 - example: *a65 + example: *a68 responses: "200": description: OK @@ -18000,10 +19633,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -18031,6 +19666,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -18194,7 +19868,7 @@ paths: - balances - flags examples: - - &a66 + - &a69 id: seat_42 name: Seat 42 customer_id: cus_123 @@ -18239,7 +19913,7 @@ paths: price: null expires_at: null invoices: [] - example: *a66 + example: *a69 x-speakeasy-name-override: get parameters: - *a5 @@ -18337,12 +20011,12 @@ paths: paginated call instead of iterating entities.get. title: ListEntitiesParams examples: - - &a67 + - &a70 start_cursor: "" limit: 10 - plans: - id: pro_plan - example: *a67 + example: *a70 responses: "200": description: OK @@ -18549,10 +20223,13 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' - for unified credit pools." + for unified credit pools, + 'ai_credit_system' for model-based token + pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -18580,6 +20257,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -18753,7 +20469,7 @@ paths: - list - next_cursor examples: - - &a68 + - &a71 list: - id: seat_42 name: Seat 42 @@ -18800,7 +20516,7 @@ paths: expires_at: null invoices: [] next_cursor: null - example: *a68 + example: *a71 x-speakeasy-name-override: list parameters: - *a5 @@ -18924,7 +20640,7 @@ paths: - entity_id title: UpdateEntityParams examples: - - &a69 + - &a72 customer_id: cus_123 entity_id: seat_42 billing_controls: @@ -18932,7 +20648,7 @@ paths: - feature_id: messages enabled: true overage_limit: 25 - example: *a69 + example: *a72 responses: "200": description: OK @@ -19134,10 +20850,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -19165,6 +20883,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -19328,7 +21085,7 @@ paths: - balances - flags examples: - - &a70 + - &a73 id: seat_42 name: Seat 42 customer_id: cus_123 @@ -19373,7 +21130,7 @@ paths: price: null expires_at: null invoices: [] - example: *a70 + example: *a73 x-speakeasy-name-override: update parameters: - *a5 @@ -19446,10 +21203,10 @@ paths: - entity_id title: DeleteEntityParams examples: - - &a71 + - &a74 customer_id: cus_123 entity_id: seat_42 - example: *a71 + example: *a74 responses: "200": description: OK @@ -19463,9 +21220,9 @@ paths: required: - success examples: - - &a72 + - &a75 success: true - example: *a72 + example: *a75 x-speakeasy-name-override: delete parameters: - *a5 @@ -19516,10 +21273,10 @@ paths: - program_id title: CreateReferralCodeParams examples: - - &a73 + - &a76 customer_id: cus_123 program_id: prog_123 - example: *a73 + example: *a76 responses: "200": description: OK @@ -19542,11 +21299,11 @@ paths: - customer_id - created_at examples: - - &a74 + - &a77 code: customer_id: created_at: 123 - example: *a74 + example: *a77 x-speakeasy-name-override: createCode parameters: - *a5 @@ -19597,10 +21354,10 @@ paths: - customer_id title: RedeemReferralCodeParams examples: - - &a75 + - &a78 code: REF123 customer_id: cus_456 - example: *a75 + example: *a78 responses: "200": description: OK @@ -19623,11 +21380,11 @@ paths: - customer_id - reward_id examples: - - &a76 + - &a79 id: customer_id: reward_id: - example: *a76 + example: *a79 x-speakeasy-name-override: redeemCode parameters: - *a5 @@ -19678,10 +21435,10 @@ paths: - customer_id title: RedeemRewardCodeParams examples: - - &a77 + - &a80 code: REWARD10 customer_id: cus_456 - example: *a77 + example: *a80 responses: "200": description: OK @@ -19712,12 +21469,12 @@ paths: - reward_id - entitlements_granted examples: - - &a78 + - &a81 reward_id: reward_789 entitlements_granted: - feature_id: messages balance: 100 - example: *a78 + example: *a81 x-speakeasy-name-override: redeemCode parameters: - *a5 @@ -19779,12 +21536,12 @@ paths: - redirect_url title: LinkRevenueCatParams examples: - - &a77 + - &a82 organization_slug: acme env: test project_name: acme-mobile redirect_url: https://dashboard.useautumn.com/dev?tab=revenuecat - example: *a77 + example: *a82 responses: "200": description: OK @@ -19799,9 +21556,9 @@ paths: - oauth_url title: LinkRevenueCatResponse examples: - - &a78 + - &a83 oauth_url: https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write - example: *a78 + example: *a83 x-speakeasy-name-override: linkRevenueCat parameters: - *a5 @@ -19870,13 +21627,13 @@ paths: - env title: SyncRevenueCatParams examples: - - &a79 + - &a84 organization_slug: acme env: test product_ids: - pro - premium - example: *a79 + example: *a84 responses: "200": description: OK @@ -19942,7 +21699,7 @@ paths: - results title: SyncRevenueCatResponse examples: - - &a80 + - &a85 results: - plan_id: pro status: synced @@ -19953,7 +21710,7 @@ paths: product: created store_push: skipped price: set - example: *a80 + example: *a85 x-speakeasy-name-override: syncRevenueCat parameters: - *a5 @@ -20018,10 +21775,10 @@ paths: - env title: GetRevenueCatKeysParams examples: - - &a81 + - &a86 organization_slug: acme env: test - example: *a81 + example: *a86 responses: "200": description: OK @@ -20084,7 +21841,7 @@ paths: - oauth_access_token title: GetRevenueCatKeysResponse examples: - - &a82 + - &a87 apps: - app_id: app1a2b3c4d app_type: test_store @@ -20095,7 +21852,7 @@ paths: environment: production app_id: app1a2b3c4 oauth_access_token: atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ - example: *a82 + example: *a87 x-speakeasy-name-override: getRevenueCatKeys parameters: - *a5 @@ -20594,9 +22351,249 @@ webhooks: feature_id: description: The ID of the feature that was added or removed. type: string + item: + description: The item snapshot that was added or removed. + type: object + properties: + feature_id: + description: The ID of the feature this item configures. + type: string + feature: + description: The full feature object if expanded. + type: object + properties: + id: + description: The ID of the feature, used to refer to it in other API calls like + /track or /check. + type: string + name: + description: The name of the feature. + anyOf: + - type: string + - type: "null" + type: + description: The type of the feature + type: string + enum: + - static + - boolean + - single_use + - continuous_use + - credit_system + - ai_credit_system + display: + description: Singular and plural display names for the feature. + anyOf: + - type: object + properties: + singular: + description: The singular display name for the feature. + type: string + plural: + description: The plural display name for the feature. + type: string + required: + - singular + - plural + additionalProperties: false + - type: "null" + credit_schema: + description: Credit cost schema for credit system features. + anyOf: + - type: array + items: + type: object + properties: + metered_feature_id: + description: The ID of the metered feature (should be a single_use feature). + type: string + credit_cost: + description: The credit cost of the metered feature. + type: number + required: + - metered_feature_id + - credit_cost + additionalProperties: false + - type: "null" + archived: + description: Whether or not the feature is archived. + anyOf: + - type: boolean + - type: "null" + required: + - id + - type + additionalProperties: false + included: + description: Number of free units included. For consumable features, balance + resets to this number each interval. + type: number + unlimited: + description: Whether the customer has unlimited access to this feature. + type: boolean + reset: + description: Reset configuration for consumable features. Null for + non-consumable features like seats where + usage persists across billing cycles. + anyOf: + - type: object + properties: + interval: + description: The interval at which the feature balance resets (e.g. 'month', + 'year'). For consumable + features, usage resets to 0 and + included units are restored. + type: string + enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + interval_count: + description: Number of intervals between resets. Defaults to 1. + type: number + required: + - interval + additionalProperties: false + - type: "null" + price: + description: Pricing configuration for usage beyond included units. Null if + feature is entirely free. + anyOf: + - type: object + properties: + amount: + description: Price per billing_units after included usage is consumed. Mutually + exclusive with tiers. + type: number + tiers: + description: Tiered pricing configuration. Each tier's 'to' INCLUDES the + included amount. Either 'tiers' + or 'amount' is required. + type: array + items: + type: object + properties: + to: + anyOf: + - type: number + - type: string + const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount + additionalProperties: false + tier_behavior: + type: string + enum: + - graduated + - volume + interval: + description: Billing interval for this price. For consumable features, should + match reset.interval. + type: string + enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + interval_count: + description: Number of intervals per billing cycle. Defaults to 1. + type: number + billing_units: + description: Number of units per price increment. Usage is rounded UP to the + nearest billing_units when + billed (e.g. billing_units=100 + means 101 usage rounds to 200). + type: number + billing_method: + description: "'prepaid' for features like seats where customers pay upfront, + 'usage_based' for pay-as-you-go + after included usage." + type: string + enum: + - prepaid + - usage_based + max_purchase: + description: Maximum units a customer can purchase beyond included. E.g. if + included=100 and + max_purchase=300, customer can + use up to 400 total before usage + is capped. Null for no limit. + anyOf: + - type: number + - type: "null" + required: + - interval + - billing_units + - billing_method + - max_purchase + additionalProperties: false + - type: "null" + display: + description: Display text for showing this item in pricing pages. + type: object + properties: + primary_text: + description: Main display text (e.g. '$10' or '100 messages'). + type: string + secondary_text: + description: Secondary display text (e.g. 'per month' or 'then $0.5 per 100'). + type: string + required: + - primary_text + additionalProperties: false + rollover: + description: Rollover configuration for unused units. If set, unused included + units roll over to the next period. + type: object + properties: + max: + description: Maximum rollover units. Null for unlimited rollover. + anyOf: + - type: number + - type: "null" + max_percentage: + description: Maximum rollover as a percentage (0-100) of included + prepaid + grant. Mutually exclusive with max. + anyOf: + - type: number + - type: "null" + expiry_duration_type: + description: When rolled over units expire. + type: string + enum: + - month + - forever + expiry_duration_length: + description: Number of periods before expiry. + type: number + required: + - max + - expiry_duration_type + additionalProperties: false + required: + - feature_id + - included + - unlimited + - reset + - price + additionalProperties: false required: - action - feature_id + - item additionalProperties: false required: - action diff --git a/apps/docs/mintlify/docs.json b/apps/docs/mintlify/docs.json index 20de088ea..f171c86f6 100644 --- a/apps/docs/mintlify/docs.json +++ b/apps/docs/mintlify/docs.json @@ -133,6 +133,7 @@ "documentation/slack-discord-notifications", "documentation/fail-open", "documentation/rate-limits", + "documentation/external-providers/ai-sdk", "documentation/external-providers/convex", "documentation/external-providers/revenuecat", "documentation/external-providers/vercel-marketplace" @@ -205,6 +206,7 @@ "pages": [ "api-reference/core/check", "api-reference/core/track", + "api-reference/balances/trackTokens", "api-reference/core/batchTrack", "api-reference/balances/createBalance", "api-reference/balances/updateBalance", diff --git a/apps/docs/mintlify/documentation/customers/tracking-usage.mdx b/apps/docs/mintlify/documentation/customers/tracking-usage.mdx index e0809faac..4c5bff968 100644 --- a/apps/docs/mintlify/documentation/customers/tracking-usage.mdx +++ b/apps/docs/mintlify/documentation/customers/tracking-usage.mdx @@ -131,6 +131,85 @@ curl -X POST "https://api.useautumn.com/v1/balances/update" \ can reset or override incremental usage recorded through events. +## Tracking AI Token Usage + +If you're using an [AI credit system](/examples/monetary-credits), you can track token usage directly with `trackTokens`. This automatically converts token counts to a dollar cost using [Models.dev](https://models.dev) pricing and your configured markup, then deducts from the customer's credit balance. + +The `modelId` must be in `provider/model` format, matching the provider and model keys from [Models.dev](https://models.dev). For example: +- `anthropic/claude-sonnet-4-5-20250514` +- `openai/gpt-4o` +- `google/gemini-2.5-pro` + +For providers with nested model paths (like OpenRouter), include the full path after the provider: `openrouter/anthropic/claude-opus-4.6`. + +Token counts are **exclusive pools**: `inputTokens` should exclude cached tokens (pass those as `cacheReadTokens` / `cacheWriteTokens`) and `outputTokens` should exclude reasoning tokens (pass those as `reasoningTokens`). Audio tokens go in `audioInputTokens` / `audioOutputTokens`. See the [API reference](/api-reference/balances/trackTokens) for the full parameter list. + + + `autumn.balances.trackTokens` requires an autumn-js release that includes + the method. On older versions, call the REST endpoint directly — see the + cURL tab below. + + + + +```typescript TypeScript +import { Autumn } from "autumn-js"; + +const autumn = new Autumn({ secretKey: "am_sk_test_1234" }); + +await autumn.balances.trackTokens({ + customerId: "user_123", + modelId: "anthropic/claude-opus-4-6", + inputTokens: 1000, + outputTokens: 500, +}); +``` + +```python Python +from autumn_sdk import Autumn + +autumn = Autumn("am_sk_test_1234") + +await autumn.balances.track_tokens( + customer_id="user_123", + model_id="anthropic/claude-opus-4-6", + input_tokens=1000, + output_tokens=500, +) +``` + +```bash cURL +curl -X POST "https://api.useautumn.com/v1/balances.track_tokens" \ + -H "Authorization: Bearer am_sk_test_1234" \ + -H "Content-Type: application/json" \ + -d '{ + "customer_id": "user_123", + "model_id": "anthropic/claude-opus-4-6", + "input_tokens": 1000, + "output_tokens": 500 + }' +``` + + + + + If the customer has exactly one AI credit system feature, you can omit the + `featureId` parameter — it will be auto-detected. The request fails with an + error if the customer has no AI credit system, or has more than one and no + `featureId` is provided. + + +### Vercel AI SDK integration + +If you're using the [Vercel AI SDK](https://sdk.vercel.ai), the `@useautumn/ai-sdk` package can automatically track token usage for every `generateText` or `streamText` call — no manual `trackTokens` calls needed. + + + ## Using Event Names In the above examples, we used the `featureId` to identify the feature. You can instead use the `eventName` parameter to link balances to different events in your application. This can be useful when: diff --git a/apps/docs/mintlify/documentation/external-providers/ai-sdk.mdx b/apps/docs/mintlify/documentation/external-providers/ai-sdk.mdx new file mode 100644 index 000000000..bf526f96e --- /dev/null +++ b/apps/docs/mintlify/documentation/external-providers/ai-sdk.mdx @@ -0,0 +1,145 @@ +--- +title: "Vercel AI SDK" +description: "Automatically track AI token usage with the Vercel AI SDK" +--- + +The `@useautumn/ai-sdk` package integrates Autumn with the [Vercel AI SDK](https://sdk.vercel.ai), automatically tracking token usage for every `generateText` or `streamText` call. No manual `trackTokens` calls needed. + +## Setup + +#### 1. Install the package + + +```bash npm +npm install @useautumn/ai-sdk +``` + +```bash pnpm +pnpm add @useautumn/ai-sdk +``` + +```bash yarn +yarn add @useautumn/ai-sdk +``` + +```bash bun +bun add @useautumn/ai-sdk +``` + + + + Requires `autumn-js` and `ai` (v6+) as peer dependencies. + + +#### 2. Wrap your model + +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 { withAutumn } from "@useautumn/ai-sdk"; + +const autumn = new Autumn({ secretKey: "am_sk_test_1234" }); + +const model = withAutumn({ + autumn, + model: anthropic("claude-sonnet-4-5-20250514"), + customerId: "user_123", +}); +``` + +#### 3. Use as normal + +The wrapped model works exactly like a regular AI SDK model. Token usage is tracked in the background after each call. + +```typescript +import { generateText, streamText } from "ai"; + +// Generate — usage tracked automatically +const { text } = await generateText({ + model, + prompt: "Explain quantum computing in one paragraph", +}); + +// Stream — usage tracked when the stream finishes +const result = streamText({ + model, + prompt: "Write a short poem about recursion", +}); + +for await (const chunk of result.textStream) { + process.stdout.write(chunk); +} +``` + +## Token pools + +The wrapper normalizes the AI SDK's usage object into the exclusive token pools that [trackTokens](/api-reference/balances/trackTokens) expects: text input (excluding cached tokens), text output (excluding reasoning tokens), cache reads, cache writes, and reasoning tokens. Each pool is billed at the model's published rate, so cached and reasoning-heavy requests are priced correctly without any extra work. + +## Model ID format + +The wrapped model constructs the `modelId` sent to Autumn using `provider/model` format, derived from the AI SDK model's `provider` and `modelId` fields. This must match a valid provider and model key from [Models.dev](https://models.dev). + +For example: +- `@ai-sdk/anthropic` → `anthropic/claude-sonnet-4-5-20250514` +- `@ai-sdk/openai` → `openai/gpt-4o` +- `@ai-sdk/google` → `google/gemini-2.5-pro` + +If the AI SDK provider name doesn't match the Models.dev provider key, use the `providerId` option to override it: + +```typescript +import { createOpenRouter } from "@openrouter/ai-sdk-provider"; + +const openrouter = createOpenRouter(); + +const model = withAutumn({ + autumn, + model: openrouter("anthropic/claude-opus-4.6"), + customerId: "user_123", + providerId: "openrouter", // Override provider prefix +}); +// Sends modelId as "openrouter/anthropic/claude-opus-4.6" +``` + +## Options + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `autumn` | `Autumn` | Yes | Your Autumn SDK client instance | +| `model` | `LanguageModelV3` | Yes | The AI SDK language model to wrap | +| `customerId` | `string` | Yes | The Autumn customer ID to attribute usage to | +| `providerId` | `string` | No | Override the provider prefix in the model name sent to Autumn. Falls back to the model's `provider` field | +| `featureId` | `string` | No | Target a specific AI credit system feature. Auto-detected if you only have one | +| `entityId` | `string` | No | Entity ID for entity-scoped balance tracking | +| `properties` | `Record` | No | Additional properties to attach to each usage event | + +## Full example + +```typescript +import { Autumn } from "autumn-js"; +import { openai } from "@ai-sdk/openai"; +import { generateText } from "ai"; +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 = withAutumn({ + autumn, + model: openai("gpt-4o"), + customerId, + }); + + const { text } = await generateText({ + model, + prompt: message, + }); + + return text; +} +``` + + + Tracking failures are caught and logged to the console — they won't break your AI features. Check your server logs if usage isn't appearing in Autumn. + diff --git a/apps/docs/mintlify/documentation/modelling-pricing/credit-systems.mdx b/apps/docs/mintlify/documentation/modelling-pricing/credit-systems.mdx index e165aede4..661ccb98e 100644 --- a/apps/docs/mintlify/documentation/modelling-pricing/credit-systems.mdx +++ b/apps/docs/mintlify/documentation/modelling-pricing/credit-systems.mdx @@ -271,3 +271,137 @@ See the credits pricing guide for a more detailed example of setting up a moneta href="/examples/monetary-credits" icon="money-bills" /> + +## AI Credit Systems + +For AI applications that need to track token usage with per-model pricing, you can create an AI credit system. This lets you define markup percentages for each model and automatically calculate costs based on input/output tokens. + + + + +Markups are optional. `defaultMarkup` applies to every model unless overridden — by `providerMarkups` (keyed by the first segment of the model ID, e.g. `openrouter`), or by `modelMarkups` for a specific model, which takes highest priority. With no markups set, models are billed at their Models.dev base cost. + +A markup of `-100` makes the model free: usage events are still recorded, but nothing is deducted from the balance. + +```ts Simplest setup — one markup for everything +export const aiCredits = feature({ + id: 'ai_credits', + name: 'AI Credits', + type: 'ai_credit_system', + defaultMarkup: 30, // every model billed at models.dev cost + 30% +}); +``` + +Or mix the levels for finer control: + +```ts autumn.config.ts +import { feature, item, plan } from 'atmn'; + +export const aiCredits = feature({ + id: 'ai_credits', + name: 'AI Credits', + type: 'ai_credit_system', + // Global fallback markup + defaultMarkup: 30, + // Per-provider defaults + providerMarkups: { + openrouter: { markup: 25 }, + }, + // Per-model overrides (highest priority) + modelMarkups: { + 'anthropic/claude-opus-4-5': { markup: 20 }, + 'anthropic/claude-sonnet-4-5': { markup: 15 }, + 'openai/gpt-4o-mini': { markup: -100 }, // free for customers + // For custom/self-hosted models, specify input/output costs in $/M tokens + 'custom/my-model': { markup: 25, inputCost: 0.01, outputCost: 0.03 }, + }, +}); + +export const pro = plan({ + id: 'pro', + name: 'Pro', + price: { amount: 50, interval: 'month' }, + items: [ + item({ + featureId: aiCredits.id, + included: 10, // $10 worth of AI credits + reset: { interval: 'month' }, + }), + ], +}); +``` + +Push changes with `atmn push`. + + + + +1. Navigate to the features page, under Plans. +2. Click "Create Credit System" +3. Toggle "AI Credit System" to enable model-based pricing +4. Set a default markup %, and optionally add providers with their own default markups +5. Add the models you want to support, overriding the markup per model where needed +6. For custom models, also specify input/output costs per million tokens +7. Click "Create" + + + + +### Model ID Format + +Model IDs follow the `provider/model` format: +- Standard models: `anthropic/claude-opus-4-5`, `openai/gpt-4o` +- OpenRouter models: `openrouter/anthropic/claude-opus-4.6` +- Custom models: `custom/my-model-name` + +For standard models, pricing is automatically fetched from models.dev, including separate rates for cache reads/writes, reasoning, and audio tokens where the model publishes them, plus large-context tier pricing (e.g. above 200k input tokens) when applicable. + +For custom models, you must specify both `inputCost` and `outputCost` in dollars per million tokens — tracking fails if either is missing. Custom models bill input and output tokens only; cache, reasoning, and audio pools are ignored. + +### Tracking Token Usage + +Use the `trackTokens` endpoint to deduct credits based on token usage: + + + +```typescript TypeScript +import { Autumn } from "autumn-js"; + +const autumn = new Autumn({ secretKey: "am_sk_test_1234" }); + +await autumn.balances.trackTokens({ + customerId: "user_123", + modelId: "anthropic/claude-opus-4-5", + inputTokens: 1500, + outputTokens: 500, +}); +``` + +```python Python +from autumn_sdk import Autumn + +autumn = Autumn("am_sk_test_1234") + +await autumn.balances.track_tokens( + customer_id="user_123", + model_id="anthropic/claude-opus-4-5", + input_tokens=1500, + output_tokens=500, +) +``` + +```bash cURL +curl -X POST "https://api.useautumn.com/v1/balances.track_tokens" \ + -H "Authorization: Bearer am_sk_test_1234" \ + -H "Content-Type: application/json" \ + -d '{ + "customer_id": "user_123", + "model_id": "anthropic/claude-opus-4-5", + "input_tokens": 1500, + "output_tokens": 500 + }' +``` + + + +The cost is calculated automatically based on the model's pricing plus your configured markup percentage. diff --git a/apps/docs/mintlify/documentation/rate-limits.mdx b/apps/docs/mintlify/documentation/rate-limits.mdx index d85ef3a03..26dae7a80 100644 --- a/apps/docs/mintlify/documentation/rate-limits.mdx +++ b/apps/docs/mintlify/documentation/rate-limits.mdx @@ -25,6 +25,10 @@ Autumn enforces rate limits to ensure reliable performance for all users. Limits When a rate limit is exceeded, the API returns a `429 Too Many Requests` response. Your application should back off and retry after the rate limit window resets. +## Service overload (503) + +Under heavy load, customer-state endpoints (`/customers.get_or_create`, `/customers.get`, `/entities.get`) may briefly return a `503` with code `service_unavailable` and a `Retry-After` header (seconds). This is transient and unrelated to your request volume -- retry after the indicated delay. `check` and `track` are never shed this way (see [Fail-Open Defaults](/documentation/fail-open)). + ## Preview endpoints are not rate limited Preview endpoints like `/v1/attach/preview`, `/v1/billing.preview_attach`, and `/v1/billing.preview_update` are **not** subject to rate limits. You can call these freely to display pricing previews to your users. 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/active-active-redis-cache.mdx b/apps/website/content/blog/active-active-redis-cache.mdx new file mode 100644 index 000000000..1a6ad4493 --- /dev/null +++ b/apps/website/content/blog/active-active-redis-cache.mdx @@ -0,0 +1,124 @@ +--- +title: "Active-active fixed our counters and broke everything else" +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: "active-active-redis-cache" +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/next.config.mjs b/apps/website/next.config.mjs index 423fbf187..afd6a576c 100644 --- a/apps/website/next.config.mjs +++ b/apps/website/next.config.mjs @@ -20,6 +20,20 @@ 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, + }, + { + source: "/blog/how-we-built-a-multi-region-architecture-and-why-we-went-back", + destination: "/blog/active-active-redis-cache", + permanent: true, + }, + ]; + }, 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/bun.lock b/bun.lock index 5a1babddc..1244c9c82 100644 --- a/bun.lock +++ b/bun.lock @@ -182,6 +182,22 @@ "typescript": "^6.0.2", }, }, + "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", + "typescript": "^5.8.3", + }, + "peerDependencies": { + "ai": "^6.0.116", + "autumn-js": "*", + }, + }, "packages/atmn": { "name": "atmn", "version": "1.1.8", @@ -666,13 +682,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.20.0", - "better-auth": "1.6.5", "diff": "8.0.3", "esbuild": "0.25.0", "fast-xml-parser": "5.3.4", @@ -2780,6 +2794,8 @@ "@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="], + "@useautumn/ai-sdk": ["@useautumn/ai-sdk@workspace:packages/ai-sdk"], + "@useautumn/sdk": ["@useautumn/sdk@workspace:packages/sdk"], "@vdemedes/prettier-config": ["@vdemedes/prettier-config@2.0.1", "", {}, "sha512-lcHyyLfS2ro282qsXKpxw+canUkOlFIGoanxt3BaNCm5K1NR8k4hGvYbFO54/+QWq12d0y/EYRz68yNQkqWFrw=="], @@ -6340,6 +6356,10 @@ "@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=="], @@ -7122,6 +7142,8 @@ "@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/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=="], "@vercel/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -8100,6 +8122,10 @@ "@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=="], @@ -9468,6 +9494,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=="], diff --git a/docker/Dockerfile b/docker/Dockerfile index 7e9bec93a..70aa5575d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -25,6 +25,7 @@ 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/ai-sdk/package.json packages/ai-sdk/ COPY packages/atmn/package.json packages/atmn/ COPY packages/atmn-tests/package.json packages/atmn-tests/ COPY packages/auth/package.json packages/auth/ diff --git a/knip.json b/knip.json index 017170f15..914fd849e 100644 --- a/knip.json +++ b/knip.json @@ -12,6 +12,7 @@ "duplicates" ], "ignoreWorkspaces": [ + "packages/ai-sdk", "packages/atmn", "packages/autumn-js", "packages/mcp", diff --git a/others/python-sdk/.speakeasy/code-samples.overlay.yaml b/others/python-sdk/.speakeasy/code-samples.overlay.yaml index b040c0111..59dabac7e 100644 --- a/others/python-sdk/.speakeasy/code-samples.overlay.yaml +++ b/others/python-sdk/.speakeasy/code-samples.overlay.yaml @@ -122,6 +122,24 @@ actions: res = autumn.track(customer_id="cus_123", feature_id="messages", value=1) + # Handle response + print(res) + - target: $["paths"]["/v1/balances.track_tokens"]["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.track_tokens(customer_id="cus_123", model_id="anthropic/claude-sonnet-4-20250514", input_tokens=1000, output_tokens=500, feature_id="ai_credits") + # Handle response print(res) - target: $["paths"]["/v1/balances.update"]["post"] diff --git a/others/python-sdk/.speakeasy/gen.lock b/others/python-sdk/.speakeasy/gen.lock index eee59c8d7..5809ef6c6 100644 --- a/others/python-sdk/.speakeasy/gen.lock +++ b/others/python-sdk/.speakeasy/gen.lock @@ -1,16 +1,16 @@ lockVersion: 2.0.0 id: 05940b80-1ef8-40f4-9878-822fb2792070 management: - docChecksum: 69c7a38357ec2752bfddc3d3fe3c8217 + docChecksum: 4792d6f91181e6f945ddcc95a9680aac docVersion: 2.3.0 speakeasyVersion: 1.762.0 generationVersion: 2.882.0 releaseVersion: 0.4.18 configChecksum: 2263d20254e354a1792248274002f650 persistentEdits: - generation_id: eeb45171-4135-499a-b86f-e278f1f1d8ab - pristine_commit_hash: 8339a7c4802bb87e0ebf10bdc7cab95b507d4599 - pristine_tree_hash: 7a4a651f3fd98b3d736fe7bb6dbc87d904b8af0c + generation_id: 5a027c14-04e4-40ed-8c06-9dbc254876a5 + pristine_commit_hash: eb82244c855e4e3e115fbd46accc4ac8bd9eaaa6 + pristine_tree_hash: 82d8459c8580318108f2b27e3b1d49eecc8074c2 features: python: additionalDependencies: 1.0.0 @@ -98,8 +98,8 @@ trackedFiles: pristine_git_object: 6a864530e70f6f17a66bcb576f191aadc0b4962a docs/models/attachadditemprice.md: id: 2fb92d280e5b - last_write_checksum: sha1:959a994f67e767ef38ac8b6a87c0da576ff7318b - pristine_git_object: 9425d1e82099e9e01070b5d33b5c3983481cf3ec + last_write_checksum: sha1:4992440acce4b1152c9f2cfa4346fbf67451fabf + pristine_git_object: 6abf7c89adfc7bc39f8b565389146ec38b9b58a2 docs/models/attachadditempriceinterval.md: id: 11d28f2d067d last_write_checksum: sha1:21ef98123bb8bc855235e7748d95c7f9b2dca6ff @@ -154,8 +154,8 @@ trackedFiles: pristine_git_object: 278b7f044f6b8e69ba9eb7ebc9d584425b0b758c docs/models/attachcustomize.md: id: 46261b03538b - last_write_checksum: sha1:4f3c1fd2b4970bddaa38509af6e3646c9c933b28 - pristine_git_object: 89a080e3fd956f37d61ce07b2f41311edb8a35d0 + last_write_checksum: sha1:820c0c787f23e793d72827008d49b892151fa4d9 + pristine_git_object: 055c8c5682d277d267a366b9143dd4d5f726350b docs/models/attachcustomlineitem.md: id: 0a326be0f45d last_write_checksum: sha1:1313d34f2ebb0fa8639087ed4da5edb8409ac5e5 @@ -176,6 +176,18 @@ trackedFiles: id: 1f2407e8c680 last_write_checksum: sha1:01adc2f34b6f6da134d5aee7cf79831b95918ea7 pristine_git_object: 103e8e64817edbc22a4016c8d664eea94ac6bcf7 + docs/models/attachintervalremoveitemenum1.md: + id: a712ee0228d3 + last_write_checksum: sha1:b2d13323b5b69ee368e9f3b807cf1925e2a29a1b + pristine_git_object: 7a26aa263e5706457cc91e8b787051a73e94e270 + docs/models/attachintervalremoveitemenum2.md: + id: 6b5062af3b62 + last_write_checksum: sha1:a50c4b83ecd204aa2026f4a4b0db9f289abf5b97 + pristine_git_object: faa2456ffb28199fd516dc06d63ee03437624169 + docs/models/attachintervalunion.md: + id: 0f1f3304080e + last_write_checksum: sha1:59b1750da5c17e417b9dd22a70e1e501424631de + pristine_git_object: 770bad90aaa198477b326e495aea3dd550e73793 docs/models/attachinvoice.md: id: 9381f0386811 last_write_checksum: sha1:643a28de3c04177fbdbbc2408ac66d2a21011844 @@ -206,8 +218,8 @@ trackedFiles: pristine_git_object: a8c162387c95654d5af34f362226e378ef419629 docs/models/attachitemprice.md: id: e7dcb698a996 - last_write_checksum: sha1:124e79a8c6ec354c3fdd71c90d2790cadb54d265 - pristine_git_object: 194d2cef28871b6c599afde67b0560280ea078ea + last_write_checksum: sha1:f38abff209b1b24886cb71b5478c48c8ef87fb82 + pristine_git_object: f7bca30691dcfaee0410a93728420e72aa12da28 docs/models/attachitempriceinterval.md: id: da3ed38a03d3 last_write_checksum: sha1:8617ac575d72925866f581409bbd40721bfc20c2 @@ -250,8 +262,8 @@ trackedFiles: pristine_git_object: 3e6eda6d5447687d5f9d6f3263ffd22f4fd40abf docs/models/attachplanitemfilter.md: id: "884913245528" - last_write_checksum: sha1:edac027910fdd364a1c828d0b388241ca39d7c0f - pristine_git_object: 8ba51d482f12baa71e2c7fab067284b48b02e4be + last_write_checksum: sha1:2f784a1bb1e7c089add1ee502dd19fe49ac1ff0e + pristine_git_object: 7542bce3876bba6636e2a0a1ca28c0bb97ced3a2 docs/models/attachplanschedule.md: id: 5dedfadc5aa9 last_write_checksum: sha1:158769029df8d0c3b0d57f044c93bbe6ac07a6a9 @@ -272,10 +284,6 @@ trackedFiles: id: 0dc57b9a53d4 last_write_checksum: sha1:460192a068649797efb1d19f3f1fe272d6d33c1b pristine_git_object: 864ce3eecd9b02b8e58f584cc52efaf30bb2dbe1 - docs/models/attachremoveiteminterval.md: - id: 4a6212df3022 - last_write_checksum: sha1:839474646bf51cd7e9d0e966fcc66c6171535aee - pristine_git_object: c79900e24fdf6e32956baa130c8bfe7836f53c64 docs/models/attachrequiredaction.md: id: e8ac88409626 last_write_checksum: sha1:d6e507f9cd1e47e617c13b69ab474f8dacd63c36 @@ -302,8 +310,8 @@ trackedFiles: pristine_git_object: e92ff2b9942c5a7995b6f303438c4ac76eca5277 docs/models/balancefeature.md: id: f59104a4654d - last_write_checksum: sha1:b2b18a3ed3ac711f821f0f1c33098d9e25461304 - pristine_git_object: f2c3a195899b943768528924a018dd292e021fc7 + last_write_checksum: sha1:f5a00f32e5e87489eac04b6a2f60c3c50db6b34b + pristine_git_object: 4c085434b14d70b4394aef7204f4ab41568a342f docs/models/balanceintervalenum.md: id: ed4c1f705227 last_write_checksum: sha1:77b828fd35efe794358527090fa4d9ad5e3e0049 @@ -312,10 +320,18 @@ trackedFiles: id: 17d1222c75db last_write_checksum: sha1:c5428ba7396416053c18769e03973800bbaef66d pristine_git_object: d95199dd2288a339631d55c7a57955fd422d1044 + docs/models/balancemodelmarkups.md: + id: 9c69b28a333a + last_write_checksum: sha1:02383eda6a568929b122117de82fb7e33340fd77 + pristine_git_object: 29f2ffe7ec57508364467f09fdb85232985a119c docs/models/balanceprice.md: id: b299e024076f - last_write_checksum: sha1:feab76c2289c4d0e195ac5a4c79def50190d3ca7 - pristine_git_object: 8a559f6c07ee8b30756041daed3aeb5bc3a2b53d + last_write_checksum: sha1:e9b384e1b3c171a7fedf6b7ca6c149ebfa63ba39 + pristine_git_object: b975f0a6bc81d0b51e9803f21c6f49850d6cdc1a + docs/models/balanceprovidermarkups.md: + id: c2ac77afa59c + last_write_checksum: sha1:aecfc2789dee1fb0a5c3ac1b9afd5ddd21f6ad4a + pristine_git_object: 5e04e89e711455363e093c92b161ef0b2f6a1a17 docs/models/balancereset.md: id: 0fd284048e85 last_write_checksum: sha1:bc163dbcf07300c4c581d42f29d7292f8f1dc954 @@ -324,14 +340,22 @@ trackedFiles: id: adceb37282fb last_write_checksum: sha1:109311364d987c833b87b84c04ad51f905be0a8a pristine_git_object: 43c658b3efea42752d73bd913e72a865bb7d9125 + docs/models/balancetier.md: + id: 75837d0d63eb + last_write_checksum: sha1:236376672410380906e461a94b49464f40f91f2a + pristine_git_object: 0bc118f3cd6c7d7b908210c66276e78bea432230 docs/models/balancetierbehavior.md: id: c5d6d94e4c04 last_write_checksum: sha1:b5b090556f2b01e915f8947d23cf3b86b035b49f pristine_git_object: 90f2ca6d455e580585ed339a4eecc9552c55208a + docs/models/balanceto.md: + id: 7bdf5d7db17f + last_write_checksum: sha1:de337b5228e750b4c8ee3da18db8b9c94401277c + pristine_git_object: 6f4fddb09c510239d439afba92dedb9ce1b01886 docs/models/balancetype.md: id: 75c2cae544fe - last_write_checksum: sha1:09514e05b48bd58bd5f6a2bd6f33ba3f0b0ad957 - pristine_git_object: 15b56e6a26511479ac90168ff02e708073684ad4 + last_write_checksum: sha1:73ef62eea22f800faff7f820ede49f5857930eb9 + pristine_git_object: 91fcc66399875f2cc681253b718dbb66d6d0fd22 docs/models/batchtrackglobals.md: id: fe146d5084f5 last_write_checksum: sha1:dad5e1556005f32abfb04c412c12bf0fbe7acc8c @@ -370,8 +394,8 @@ trackedFiles: pristine_git_object: 233638e5fbd17e233b70e2d9cdd470660434e3ac docs/models/billingupdateadditemprice.md: id: 8bc8f0605d9e - last_write_checksum: sha1:f6c2858de9000e8ba459e140f7faa41e07b58c2a - pristine_git_object: 159b26f6a47219d67b230f5d3b35cefebb5c17c7 + last_write_checksum: sha1:c47bff44d4abbcba8c4d0c35e524ea5d13a50b0d + pristine_git_object: 294ebe9ac677d635f13b6e9c114ea25f376e0bd6 docs/models/billingupdateadditempriceinterval.md: id: a705c2fdc41e last_write_checksum: sha1:ab1fe95e901bfafa6350f6653c35838e1148d1d6 @@ -422,8 +446,8 @@ trackedFiles: pristine_git_object: b84d2bb18ea93dcf6314b5d3193dc413e94e897a docs/models/billingupdatecustomize.md: id: 72b14fed6d26 - last_write_checksum: sha1:48e0604661b2ed1d1cc67982937bfc10b348f667 - pristine_git_object: 784b825bdd0d64e7147004f7affb16cf59c1a9c4 + last_write_checksum: sha1:45e00e967bbb5a98015a2bb94441624b9383a335 + pristine_git_object: 1eda061afedca60d9fa4a3aaca589edbcd1f34dc docs/models/billingupdatedurationtype.md: id: fd1bfb929148 last_write_checksum: sha1:4ecc7d22519fc4f6b45ca394942f2e2bb7d6e008 @@ -440,6 +464,18 @@ trackedFiles: id: be51d630ee22 last_write_checksum: sha1:ebb2f51852f2b74fc04466c196cce1255267e576 pristine_git_object: 0f1202c71c3c2d1ee807789de058c33831cc8aae + docs/models/billingupdateintervalremoveitemenum1.md: + id: 4ea9989241d0 + last_write_checksum: sha1:7924b9d7b8838a40bc86379fda0d20168769b61f + pristine_git_object: 7bdd0679aaead0b1157aec189cccc864c11fabe4 + docs/models/billingupdateintervalremoveitemenum2.md: + id: bd19a263c013 + last_write_checksum: sha1:62d747fa52c0d73709c54187280ab12e3b062385 + pristine_git_object: 98b366cee12b97c1039b61968f9535a840aa1519 + docs/models/billingupdateintervalunion.md: + id: 0608473f2eeb + last_write_checksum: sha1:9036a858eda2f4cf2aab0613790b6b6ff8b5b802 + pristine_git_object: a060070f8cd5e7c180530a1db2e9432746fde99b docs/models/billingupdateinvoice.md: id: 0a9160e72785 last_write_checksum: sha1:c60c8f769dcad70aff1097765351f2fb13c516b8 @@ -470,8 +506,8 @@ trackedFiles: pristine_git_object: 9dd457017ccb430dc55ffcdcf89db87d38cefba1 docs/models/billingupdateitemprice.md: id: c461358e0385 - last_write_checksum: sha1:07d0b2225f8828e527976cf097ce544a466d4117 - pristine_git_object: 8eef61ac0df63bb18e1fe449685f3bad51820c1f + last_write_checksum: sha1:7a1b07af8c4d21029b779fbe2dae6c52da4e54f2 + pristine_git_object: 35f5f2d468d3436590ced98d02ed43377670c09b docs/models/billingupdateitempriceinterval.md: id: 0cec75e3f2ae last_write_checksum: sha1:a0b1cee7b78bf47a51bdaa18f82fc83082474c5c @@ -510,8 +546,8 @@ trackedFiles: pristine_git_object: d84ae9637552c6ef5d6fbd244fb13cfc5d323cd5 docs/models/billingupdateplanitemfilter.md: id: ca302430b6be - last_write_checksum: sha1:e4c11fcca406a6d85e97bd976e9e45eab3e0667e - pristine_git_object: cf901051fd271b52ee89dd55363fc7b8c385fb7e + last_write_checksum: sha1:f74c5419932093acd78f2abdbd17df905ef723c6 + pristine_git_object: ba4512253252a27a7e5c65bc6abe386416ac2d35 docs/models/billingupdatepriceinterval.md: id: 35fa4e54bdfa last_write_checksum: sha1:c0da44caf43fa226e1f4c61f66105a12dfaf7799 @@ -532,10 +568,6 @@ trackedFiles: id: e83ddd11a1c5 last_write_checksum: sha1:e021d64dc5621857687fe389936f7ae2591d24c5 pristine_git_object: cdbef678f37b5cb8912bf05e0e35c094bcc46e71 - docs/models/billingupdateremoveiteminterval.md: - id: 8703e662b731 - last_write_checksum: sha1:0d83cce1187fd3b15f5f271b84aaf8b313955bb5 - pristine_git_object: d3b4f03026d79138d1636df376a2955f23037c5f docs/models/billingupdaterequiredaction.md: id: 3f84a7bd8718 last_write_checksum: sha1:999727446d5c53ece4c816c145063ce6f0ab0ee0 @@ -578,12 +610,12 @@ trackedFiles: pristine_git_object: 177d1e4f800e22191658a9c7870fada3616f5101 docs/models/checkfeature1.md: id: 4c3370c023d0 - last_write_checksum: sha1:3027b27ea338cbaf22d7d3a5fc44fca25e196ff5 - pristine_git_object: 45c044f1385f2eaf100a06a79232f56d1031ee1a + last_write_checksum: sha1:57520ac5f2602f6c66c39a1b65942d00335c80b9 + pristine_git_object: ef177b6d2059906fa0d96aa795ccaae320c39395 docs/models/checkfeature2.md: id: ea0c32d843fa - last_write_checksum: sha1:00091321a41162e269bf77fb3ae1a0b192d17731 - pristine_git_object: ac1ad6e14d356c9b618c9ae81333a031f34f81e1 + last_write_checksum: sha1:8f94c27b9c7150e2b56576789def40f7e5ad0ef9 + pristine_git_object: 5a2716943bd76f97ba3a25a05ad2c9e8de02c8da docs/models/checkfreetrial1.md: id: 6feb912cba84 last_write_checksum: sha1:17b963fcd3b29af462db2f0e8960e26233179ad1 @@ -616,6 +648,14 @@ trackedFiles: id: 5f638752293b last_write_checksum: sha1:03f54dc8923833000ae382c045467a5b72d1ea0d pristine_git_object: 6a029c8c5367cce65667868341d1026f540f416b + docs/models/checkmodelmarkups1.md: + id: 43054baec131 + last_write_checksum: sha1:00f0f34ee9ed1d87f9df49cb21c9ac67a6f9c95a + pristine_git_object: 0edb4cbb7d146ad5c476858498fabc49167c8a88 + docs/models/checkmodelmarkups2.md: + id: 8b0076a9e720 + last_write_checksum: sha1:8b9bd704b7a21c4d958aa804563615085499a286 + pristine_git_object: aa77b4a5a980a186dd21198194f264a18963384d docs/models/checkondecrease1.md: id: b399a28fcf6a last_write_checksum: sha1:58a534282c58ca0ded69f5185476f363be922d5b @@ -652,6 +692,14 @@ trackedFiles: id: f002dbaf8c20 last_write_checksum: sha1:3140655b5efa528c4d839550d3d17a04de6f3f43 pristine_git_object: 340e33a4d0afb0f7433a7b6f30d0329bd9f4015c + docs/models/checkprovidermarkups1.md: + id: 97fe1e198983 + last_write_checksum: sha1:49976edb6b93d0ce0a99afb1368388f4eefcfa6c + pristine_git_object: 9158345cb261d2a811b847967e6803059d120bf8 + docs/models/checkprovidermarkups2.md: + id: b8433b15d382 + last_write_checksum: sha1:4a45c7ad634f4498af29433fb460e219ca1e44c2 + pristine_git_object: 06d7869b46460e0e4edbfd568b304945f439b428 docs/models/checkresponse.md: id: b988b0f4b781 last_write_checksum: sha1:9522d4ecea7631ce3ae3d80b341a068ccb2dcca4 @@ -738,8 +786,8 @@ trackedFiles: pristine_git_object: 5d6211aeda9b6edf22bdacc3753cfad11b80126b docs/models/createentityfeature.md: id: e029c7ffe3b3 - last_write_checksum: sha1:bf16dd1e2a5f791e737caa9a6ad3a492be32ef0f - pristine_git_object: 0b8522563aa9623fd20bbc5da1a735334520c529 + last_write_checksum: sha1:dfa680ff95ebb14b21f9ab08463b28e539e090bf + pristine_git_object: c8d743cf6f8ae5eb49629ef2e901070f61dde31d docs/models/createentityflags.md: id: 385fcaeffedf last_write_checksum: sha1:1b57f843f5be2ad5b13832eec4339edfc45eebef @@ -752,6 +800,10 @@ trackedFiles: id: b69787a407d0 last_write_checksum: sha1:7c8838a0d63b49b75bebb0644764490c4c01d0c3 pristine_git_object: 74065c0fc27899e1cc41df0e69636070661ec269 + docs/models/createentitymodelmarkups.md: + id: bd72f6db0b0a + last_write_checksum: sha1:efe4ac1d74ca35a1b81660ec4b170befcec2eb87 + pristine_git_object: 6199b77184f8736017ebb3cbf7254eb360f890f0 docs/models/createentityoverageallowedrequest.md: id: a44c1b2fa459 last_write_checksum: sha1:f44327edf69c354f2ea58e4854c659d52fe7c58f @@ -768,6 +820,10 @@ trackedFiles: id: 555247d34ffe last_write_checksum: sha1:021ddc24001d0f0ecd9e91a7844817e67811898c pristine_git_object: 0a980279dbe3f35c22847e8dcd6ee62bbb43e106 + docs/models/createentityprovidermarkups.md: + id: 277791aa5bc9 + last_write_checksum: sha1:1754d37ce0bae5b12c1d61a2ed32d9abfb7d060f + pristine_git_object: 663198f5e43700cefa21c4238547eb19c6cb15a6 docs/models/createentitypurchase.md: id: 79988cc65fd5 last_write_checksum: sha1:f977fa9ce66b960404ed2e700f3346d5104774d8 @@ -810,8 +866,8 @@ trackedFiles: pristine_git_object: 341a49c03e653aeb016a8ab58d85d43983953ddf docs/models/createentitytype.md: id: 32635d8bbeec - last_write_checksum: sha1:23b40023f5a46fd8d8309b7bd11cb2c16168601d - pristine_git_object: e9289481db3fca2916797cd0583a9b407c6c9dec + last_write_checksum: sha1:6f13f9e3563f3cba25ac4e62729d1e67495215da + pristine_git_object: a4036c520f9616d1985627315e532a9b94ce9f6b docs/models/createentityusagealertrequestbody.md: id: 6c9e600d7cb2 last_write_checksum: sha1:3b5300a61727c5e485166cdb8575939594b3068e @@ -820,18 +876,18 @@ trackedFiles: id: 19c3b14a71d4 last_write_checksum: sha1:c30f3816ac20c26cdf733b791de0874e1e55e959 pristine_git_object: e7054bd92cb00074fe217c5b0a6524c5a3df62d2 - docs/models/createfeaturecreditschemarequest.md: - id: 14932a0ada38 - last_write_checksum: sha1:3ed6eb2080d0f207c81415025fca068be6dd3e6b - pristine_git_object: 1dc92bbdc33ba592974b62e77c699909ae3c1118 + docs/models/createfeaturecreditschemarequestbody.md: + id: b0f754509f36 + last_write_checksum: sha1:0772d4685e47ff660242eb220c664a9c48927a28 + pristine_git_object: e38c9282db8b273431d167ee76b0a65619ea5977 docs/models/createfeaturecreditschemaresponse.md: id: 9e8ac7bd3cec last_write_checksum: sha1:fb0c5ed04ae3d62575357ac2e5ad9ea038faeea1 pristine_git_object: dca67045658cffeb57d3a0777090c7c6bac9b47c - docs/models/createfeaturedisplayrequest.md: - id: 08ca0b003300 - last_write_checksum: sha1:7dbecef84aa3a72426e96de78e02c2c80a45a7b4 - pristine_git_object: 3d70916069c1767d261f8c8b98bc5721a71b6090 + docs/models/createfeaturedisplayrequestbody.md: + id: 18de1abb3498 + last_write_checksum: sha1:132f13c2d64a5030dd4b6807a3fce1bbab88e1d0 + pristine_git_object: f96ca24ca45814fb7ee205c6481db7d69b128f8d docs/models/createfeaturedisplayresponse.md: id: b1690a40dff7 last_write_checksum: sha1:808c6996e7fd4ddc47894001c6bc92902421fdd9 @@ -840,30 +896,46 @@ trackedFiles: id: fbfbf3e3390f last_write_checksum: sha1:bd5799f08183d5c0349647ea607bf8df33484ddb pristine_git_object: 48c89cf2b54e6c8bc84920ab0357acfe77e45963 + docs/models/createfeaturemodelmarkupsrequest.md: + id: c035877de4fb + last_write_checksum: sha1:d4b16a2fe3e4b57fddcee2eaa30576e9dd7bba61 + pristine_git_object: 893e8e13b07088a055ae13e22697c77ff495d160 + docs/models/createfeaturemodelmarkupsresponse.md: + id: 3771c5ca743f + last_write_checksum: sha1:54ac78a6e85cdc192fbf8990cac567dfc7ad7456 + pristine_git_object: b4d37287df6d62cbe5bfd4c7dbbf358bd446a1e7 docs/models/createfeatureparams.md: id: 92ae761a945d - last_write_checksum: sha1:8fc0bf85c1a3e27fc41c0f49d3a2abfa0e9e241a - pristine_git_object: b390b4f2bf39cb257997a61ae92f9aa4b7990434 + last_write_checksum: sha1:a7e377a489fa70d7aa7e8ca6a83604167178f88a + pristine_git_object: 01fe7ee9cb5873e99cae1e037a74ab229e772423 + docs/models/createfeatureprovidermarkupsrequest.md: + id: 477bd511307d + last_write_checksum: sha1:b7446f29ccd720d2ccbb6df32626a84943ec9535 + pristine_git_object: eaedd0c887c78c9dfa997104e12561f030060287 + docs/models/createfeatureprovidermarkupsresponse.md: + id: e0e4d227eb38 + last_write_checksum: sha1:86e2a828ce5876b3311e52ccf3082efee80e2799 + pristine_git_object: cfd8407973baf855fbc3c6908916db88067659a3 docs/models/createfeatureresponse.md: id: ddf84fc51d7b - last_write_checksum: sha1:95cdb5d2b11efb2313181e6f404bfaa60c6b9eec - pristine_git_object: eaaac3dc55e7e703c2801c86f267cc84cd1463f2 - docs/models/createfeaturetyperequest.md: - id: fe6cd361268e - last_write_checksum: sha1:691716b1b97a99fd39d1c6fac575ad6e7d3a8d48 - pristine_git_object: 38d707117cd84c9b5fde3360d169f476107e9271 + last_write_checksum: sha1:072b5388d11b6dc934f93186b4b7821f06fd111c + pristine_git_object: 90ae7b38eca2a84ccc47b07020ee53480d41ffd1 + docs/models/createfeaturetyperequestbody.md: + id: 6433c882f1eb + last_write_checksum: sha1:0109eab84639ba894b1266379edb6d5ddeba5a71 + pristine_git_object: baad553a3c7382c3478975d00132bd22bda35b1d docs/models/createfeaturetyperesponse.md: id: b48617bd07bb - last_write_checksum: sha1:77ac606e5f0b689983ff6c609fdae20ef2ac2fec - pristine_git_object: e4428c360251496590101bafe621d2622acb8074 + last_write_checksum: sha1:a5ce17e0cdab3c3fb935e9ba158e4443f48198bf + pristine_git_object: 4a79b48d6f0636cb3b3199dd005a1490a5305906 docs/models/createplanattachaction.md: id: 51fddcdddcfe last_write_checksum: sha1:9418a9072d39278ed789c16429958ee8b4e675d6 pristine_git_object: 18912b7d8ea75eb28d8c7b26b260cbe0b7b3e5ac - docs/models/createplanbillingmethodrequest.md: - id: aad7cd71a9d2 - last_write_checksum: sha1:7ca5d093290ef5c5cfe9c1a912557e1712130470 - pristine_git_object: dbc67b61a533d4902f48db8a2858b5f6d7070d7e + docs/models/createplanbillingmethodrequestbody.md: + id: 79c0ad3a0e0a + last_write_checksum: sha1:312a6f6628e83710bfe56b44284ff001a5c90da6 + pristine_git_object: 56f561c6d1cd2e41547ae3d6a2354b8807810881 docs/models/createplanbillingmethodresponse.md: id: b307da22d211 last_write_checksum: sha1:cb2e4a736b4ecc159767a293453ff87f9ed0c9eb @@ -896,10 +968,10 @@ trackedFiles: id: 7ddb245035c3 last_write_checksum: sha1:8f0361ff0433ad6468bfbd5f284cf446571cbcd9 pristine_git_object: 7343391ed2b1ae2ad30b1f140c9616eeb77555f5 - docs/models/createplanexpirydurationtyperequest.md: - id: 8bce768b39f2 - last_write_checksum: sha1:613b0344d6211e5da5ac517f69103bc7cf87f01c - pristine_git_object: 897223bad7e0b51d451ab358e47720e862274374 + docs/models/createplanexpirydurationtyperequestbody.md: + id: a2488ecf88a4 + last_write_checksum: sha1:e84cf275285cb8b8c99ceca86e5180ca8029ab9d + pristine_git_object: 5a12a4fc2982475a0fe77179fa66ad82ad77938a docs/models/createplanexpirydurationtyperesponse.md: id: 00879e9d2363 last_write_checksum: sha1:626ca951ef32e1019dc32dc7b8f183cc277366b2 @@ -928,18 +1000,18 @@ trackedFiles: id: 89626fca02fd last_write_checksum: sha1:05f65e8c7f010aa6480578396ee254417db4b421 pristine_git_object: bd384b452456016555d00559f6b3dab88c1ea4d2 - docs/models/createplanitempriceintervalrequest.md: - id: 18e0dca1ea6b - last_write_checksum: sha1:a789b4621eacdc2744f6257d3d2ec7c5a9c124ad - pristine_git_object: 5894f12d49cfaec84941e5612027928689d60d7c - docs/models/createplanitempricerequest.md: - id: baaf8916c3b6 - last_write_checksum: sha1:b3294dc1d5c7340bbecafacf66d64957b530a7d7 - pristine_git_object: 0fb41e0cee1ee3eb573027253dfcfc36822f2356 + docs/models/createplanitempriceintervalrequestbody.md: + id: 3e02fdb06166 + last_write_checksum: sha1:3b5601e57b47d666722ae47b6b9b61f44b332339 + pristine_git_object: e994a4ba93ad7468b8fe9c95bc0ac1555285031e + docs/models/createplanitempricerequestbody.md: + id: 29d98c43b94f + last_write_checksum: sha1:78cba91d0f40d6e58b9871870041a534ae295417 + pristine_git_object: 7ba9974e7bca9a03454ec48b4e4570b4842b2cd4 docs/models/createplanitempriceresponse.md: id: 569ccfab75df - last_write_checksum: sha1:81886e739943d3d72c465bde83064499519ea4a8 - pristine_git_object: 0e45fcef8a9c84780166602dc20ffbbbcd1463c1 + last_write_checksum: sha1:12fd381cdfbca8c74884cacd4ede2dd072f92505 + pristine_git_object: 19fef177c7353a66f1fc40bfa48b0eb3e68262e8 docs/models/createplanondecrease.md: id: 13e92221faaf last_write_checksum: sha1:afaf4c77970e09e2f8cba839ca6a5a4eab7ed028 @@ -958,20 +1030,20 @@ trackedFiles: pristine_git_object: a7cbec89d5e586f2e506b7fac6a14d299e227ee2 docs/models/createplanparams.md: id: 83565eddc151 - last_write_checksum: sha1:e2927eb8c508f5ae53629505ab11c2cd6d188ccd - pristine_git_object: 7bcd5fa8cee4ae79b41f968973e1e4e778edd912 + last_write_checksum: sha1:b78daf3aef9af0e5bf39e3e5fac9e75e97170cf7 + pristine_git_object: f59c84051a570ad89900b874ac9ed392e97a9b62 docs/models/createplanplanitem.md: id: 1b9012d7afb1 - last_write_checksum: sha1:0671625d4e92d02eba01ee9b1a9e49effcd3fc96 - pristine_git_object: 1d4ba0e2249594f29a26bcd06be6f91f048b1475 + last_write_checksum: sha1:9bb59cc671cca335a7f102f73d3664c7fef4fdef + pristine_git_object: 46155234efc8b319599075acf5d85030f7fc5130 docs/models/createplanpricedisplay.md: id: c54110e927f1 last_write_checksum: sha1:ea57765f6d0be4c8b0b85b928d10461584293df2 pristine_git_object: c329aa588b3946be41c98de8407001fad94759d1 - docs/models/createplanpriceintervalrequest.md: - id: e2978f16aade - last_write_checksum: sha1:be607cfb579147ddd94dd1a82a192fa825ae8355 - pristine_git_object: d15692141667a70c734901f70a0f7874748007ee + docs/models/createplanpriceintervalrequestbody.md: + id: 9e666fa0d97a + last_write_checksum: sha1:4f723b72ad5d7d4e807de1af802b66e0fa33c638 + pristine_git_object: a6f77ed27d6fb3e2f103f70097419daad5855315 docs/models/createplanpriceintervalresponse.md: id: f872fc43537d last_write_checksum: sha1:c4dcd437608e05903abbcaaa3b5436e89e2c3751 @@ -980,10 +1052,10 @@ trackedFiles: id: 24549a1388a1 last_write_checksum: sha1:48389d49de582e1ce66bb73ac3a97e14f9d09421 pristine_git_object: c1e2f83b4b2745109f05429dc564381e35566913 - docs/models/createplanpricerequest.md: - id: f12e1c990ec1 - last_write_checksum: sha1:78d33eb36c61df233aeed99741f184760683cca8 - pristine_git_object: b9d8ba0d190ef6431423a3b93f937af5d714d1f4 + docs/models/createplanpricerequestbody.md: + id: 673e832b4993 + last_write_checksum: sha1:e478992cafc09e27855ea46d9c099ce999c11534 + pristine_git_object: d18593e014d4ac680dac432711c5c23f53b5043c docs/models/createplanpriceresponse.md: id: 23d4d42c22a8 last_write_checksum: sha1:525d1bbbd2e1ed7a29a1729c9724082b7b54dc78 @@ -992,18 +1064,18 @@ trackedFiles: id: d9de9e0fff5e last_write_checksum: sha1:dbeb9ac733330c246d3ddecba924e80d9d6245ab pristine_git_object: fefa0c8a14e9b08d8317fc8d3733dbb42f1dca09 - docs/models/createplanresetintervalrequest.md: - id: 0cddef9265c3 - last_write_checksum: sha1:af71157c843c2697f551d58b091bbfce73b380b0 - pristine_git_object: 77e07403fee45b00512fc707de405b9000abf7dd + docs/models/createplanresetintervalrequestbody.md: + id: 0e59f9d1877b + last_write_checksum: sha1:8b5d48d175ed854d3b9b25d29db956977e16596e + pristine_git_object: df245e0d209bc154cba70cccfa81e25cc05e11b4 docs/models/createplanresetintervalresponse.md: id: b17cd3a6cba7 last_write_checksum: sha1:a7fc918adc597dd6a8091837d069ef23b783d966 pristine_git_object: e23ed719cb9b9068ce42ba8db9a4256bb582faf3 - docs/models/createplanresetrequest.md: - id: 4269e3fff10d - last_write_checksum: sha1:81ba6a1a229a056e81142b59227d39db3a6b6f4f - pristine_git_object: 4a3159f5d66a2a2ad78a281939bd87e3633dcc90 + docs/models/createplanresetrequestbody.md: + id: f382a5b5e71b + last_write_checksum: sha1:09c989d065bfcca835cf62025bd2958f1151ece1 + pristine_git_object: 3e8ddfd5a362394cb48ae696eff5f9a51e9a9334 docs/models/createplanresetresponse.md: id: 738ead8f3a77 last_write_checksum: sha1:9ef5e1b8530a388284c4145b95eb22ea0bcd48fd @@ -1012,10 +1084,10 @@ trackedFiles: id: 72de4e2745bb last_write_checksum: sha1:311f175443401a8cea68bad160d54e8bcc5806d5 pristine_git_object: 54f358896ee1c0a751881590b3fc5aad59ae49d6 - docs/models/createplanrolloverrequest.md: - id: 01b2ca1d23fb - last_write_checksum: sha1:455e7de95238ad1b496d10159c83a40c4bb0fd8d - pristine_git_object: dd3b5be762a2c63a56422e63800a4228a124ad19 + docs/models/createplanrolloverrequestbody.md: + id: 268de03617c4 + last_write_checksum: sha1:3543240050316ecd78d2b2d816e35d6dfaa104f1 + pristine_git_object: 87dc729eb0d21d65d7e45e452712479511aedfe9 docs/models/createplanrolloverresponse.md: id: e8134e24a5a8 last_write_checksum: sha1:72feb0d95a2c8e5b2d019a888e68a193578e2291 @@ -1024,26 +1096,34 @@ trackedFiles: id: c67e48774d6f last_write_checksum: sha1:0e73a49760bb7b7240413e82280e4bf14527e81f pristine_git_object: 0a02b6243e4951579c5296f3b0de279a6734bb53 - docs/models/createplantier.md: - id: c582ff76d889 - last_write_checksum: sha1:159c3f51cdcde0027f66b0dc8ee14e44a3400c39 - pristine_git_object: 9cf6204964346d5215a14551c0cac01660077d29 - docs/models/createplantierbehaviorrequest.md: - id: 2a8236046547 - last_write_checksum: sha1:884fc740aad1e4d1929feac86e4f8b3984fd305b - pristine_git_object: da7dc3726e2780838412c24fc2d12bd92bbbb21e + docs/models/createplantierbehaviorrequestbody.md: + id: a47f2b8fceb5 + last_write_checksum: sha1:17492067104589da99cf7cd3e392c10a01ab08a6 + pristine_git_object: b5b8602387da0224dca0434f21eb9271585d8d94 docs/models/createplantierbehaviorresponse.md: id: 491d2246191a last_write_checksum: sha1:6fb032a0cde153e82c9ac9770fcf90fc7b307275 pristine_git_object: c20a82090b832368daed398af77dca93bf0c78fe - docs/models/createplanto.md: - id: 3833b33d43ec - last_write_checksum: sha1:bcb546882173424d017121b0557a8ffeaa27ab55 - pristine_git_object: 420a9bf4ba28c64093d17fc095b8af35fe9f0d8a + docs/models/createplantierrequestbody.md: + id: cdd6b2dc56da + last_write_checksum: sha1:77aeecf4c755100e17991bb29ff34e6a8c27235e + pristine_git_object: 25e0b542fe51366bd6bb1be9aeef0b0e2a2c3b7f + docs/models/createplantierresponse.md: + id: ff3bdb303669 + last_write_checksum: sha1:dfcabf25f6afe62866b255ee0dac5b040ab8ff3e + pristine_git_object: 3015adeacee580770ac89f446783b390b5699df5 + docs/models/createplantorequestbody.md: + id: ffe25b4f9d50 + last_write_checksum: sha1:63b5ffc38d48e7c12dc1da8abcde411f0500dde7 + pristine_git_object: 4865c530518bc38c3dcaecc8a9cabeca0fc2f281 + docs/models/createplantoresponse.md: + id: d83f8ec99877 + last_write_checksum: sha1:52b99a3b91c8543b6015ec69f417f29e21884e77 + pristine_git_object: 767b75b2664c8e1fc150756cd97056a20ecf283a docs/models/createplantype.md: id: d50ef0678a3b - last_write_checksum: sha1:a42c152f56d182a6561686883df41eb1530c3605 - pristine_git_object: e663cedb4dcf7df50c677067c84556d8da938492 + last_write_checksum: sha1:f073a3ff7c8d46626f77f185080d827eda1b00f9 + pristine_git_object: 77005454d55d94da3efa380fcd43ed0f8ab88736 docs/models/createreferralcodeglobals.md: id: 368eaec7a9b4 last_write_checksum: sha1:abfc0223ac79b4ce82dc286b7eaec9ee7003a10a @@ -1056,6 +1136,58 @@ trackedFiles: id: b746d21856be last_write_checksum: sha1:ef75b823c030983dfafcf82298a1ab67e8482682 pristine_git_object: cef67b1f14cedbdbac9b0af23e05bd4fc0856294 + docs/models/createscheduleadditembillingmethod2.md: + id: 0f3dfb96aa34 + last_write_checksum: sha1:e4551fa393612553ffb00f0f2664438a9208999b + pristine_git_object: c0b272469e8cf179d69d3a56b8de899476b76a50 + docs/models/createscheduleadditemexpirydurationtype2.md: + id: "217424949892" + last_write_checksum: sha1:4a83fb3307ee7966b4d1514080a9538c4f58ce8f + pristine_git_object: 1dbaf0f1650b45d706005c950773173f8ed50557 + docs/models/createscheduleadditemondecrease2.md: + id: ce219404ce05 + last_write_checksum: sha1:456ffab1bf202cbd33d2503b533f3ac1ceaae569 + pristine_git_object: 9c43804539278e2d8994553c043656ccc640b143 + docs/models/createscheduleadditemonincrease2.md: + id: 262a42d76cbf + last_write_checksum: sha1:73276d84a688a634d75ced1069ea52e6e51f324f + pristine_git_object: 55581521713334d88a2b6524d9753590d9837e1b + docs/models/createscheduleadditemplanitem2.md: + id: 0f7741cdf24c + last_write_checksum: sha1:2226efbd34493c948505e978e2cda18970aa2fbc + pristine_git_object: acbb2d3118f12a2c96c107d3a0c08b9f4d2ba302 + docs/models/createscheduleadditemprice2.md: + id: 1a89c3b5c7d9 + last_write_checksum: sha1:fc89e78bb7a732b87b38e65a658694bbb996cca0 + pristine_git_object: 0b82825220c3514c9fccc57215eec64af46cc915 + docs/models/createscheduleadditempriceinterval2.md: + id: 528fe8574f8e + last_write_checksum: sha1:0420192b972aae45029808156619a903188eea1a + pristine_git_object: 2e0c5f66d8865c2f5cc3a81e08dce5eddf883f2c + docs/models/createscheduleadditemproration2.md: + id: a0249ddf91cc + last_write_checksum: sha1:5b4368cdc8c678ceec5ee32eb94e55a7ddcac08a + pristine_git_object: c1fe15547e6b17fe7ecbc6f1b13dc4f928b7fdb4 + docs/models/createscheduleadditemreset2.md: + id: f0b69d72fd54 + last_write_checksum: sha1:b1c4126fd04ad35aeefdf3bdd0552284bfd255ff + pristine_git_object: 383e2c60f6ac68bdd62ecf96cea11501bf186348 + docs/models/createscheduleadditemresetinterval2.md: + id: 7de71dc6c807 + last_write_checksum: sha1:302d3045281325ee351c2a5e84b613320d9fb34a + pristine_git_object: 53fcd71d2aa0112f9c5e612a9db8a36b0116213d + docs/models/createscheduleadditemrollover2.md: + id: c3c82ebe9b3e + last_write_checksum: sha1:ec5fbb87b1f0b8ef59c56611998c51db2c265146 + pristine_git_object: 62404b6da6dc5266e651199a0b0a70bcd39a7dd5 + docs/models/createscheduleadditemtier2.md: + id: 1a3460da25fe + last_write_checksum: sha1:91341b3a6a677873850c6cb6cc9cb7c0e961b7a3 + pristine_git_object: 1b216e1d8aee2caeb6bc0481be3aa649980096d6 + docs/models/createscheduleadditemtierbehavior2.md: + id: f64d37d05bdf + last_write_checksum: sha1:f51083b23595a5420fba4d5fc874b9afbdb6796e + pristine_git_object: 667fe98b7ca260cd9e1c847f3484b57d26764b7a docs/models/createscheduleattachdiscount.md: id: c46ced86d565 last_write_checksum: sha1:7125edc16218dcdf02afea2aecaae044367e8f96 @@ -1064,22 +1196,14 @@ trackedFiles: id: 085a5cb811d8 last_write_checksum: sha1:9f79c1efa249af5fabf138941328be472984ea59 pristine_git_object: fe0effdb8f0bd0655e56dc020c1b56df82fb51d2 - docs/models/createschedulebillingmethod2.md: - id: 6c9fcbc61773 - last_write_checksum: sha1:ab53f8749b49ad95e6817f1103f67b5f978b531a - pristine_git_object: 5b0bd7d4cc75dec2402751cb541fd2a6c23c24c7 docs/models/createschedulecode.md: id: ae8797299cb3 last_write_checksum: sha1:5e22eb9e2ef88e6b4d0956b452f000add08ca68b pristine_git_object: de1afe7be3a3b8e8f31d8ef15bde78b740a6935a docs/models/createschedulecustomize2.md: id: 2d3dbf831687 - last_write_checksum: sha1:9d31363e61674e6d0727605ab055a31663cc9bac - pristine_git_object: 9f5ffcc21a8906d6f4be3d95edab7a15a72ae09d - docs/models/createscheduleexpirydurationtype2.md: - id: 8a8f29337d99 - last_write_checksum: sha1:fb8a90e1f143399de72c3acea42946e6fb6fc927 - pristine_git_object: 90be270664a6b47f777d426bb52e79c8f597d6b3 + last_write_checksum: sha1:542fec4917956ec5843e0e93febdc26b36587f62 + pristine_git_object: b2dfe75d5ff4d7e77ad258e7b69e3fb01a64edc2 docs/models/createschedulefeaturequantity2.md: id: c9c29754b283 last_write_checksum: sha1:f8cbdc97b69096b9b7b4a634272786832d2d1d0f @@ -1088,6 +1212,18 @@ trackedFiles: id: aadf183113ab last_write_checksum: sha1:cc812b72171a296101e97908a1fd1fdad2454f73 pristine_git_object: c5a13b0eb194df2c570215f65ea1921f40c0db39 + docs/models/createscheduleintervalremoveitemenum3.md: + id: 5bc6e70f2b47 + last_write_checksum: sha1:1ff9a50b77fda64b5839b20c7b9dbe6055ea36f1 + pristine_git_object: 66f5a6fabd2b051f3b075d082c1f23fad2457039 + docs/models/createscheduleintervalremoveitemenum4.md: + id: 541bb85f754f + last_write_checksum: sha1:03890bac2457e1992bb37645233e844ef0a84303 + pristine_git_object: dbbb8fa0a9868a8b82f124925a832af408d50591 + docs/models/createscheduleintervalunion2.md: + id: b7ef292a780c + last_write_checksum: sha1:69f9618dbd2b2d0d96de053b9a36835216ef7de8 + pristine_git_object: 8c29db439b1e2e4f40ed48e3f1bc97b1f6089e69 docs/models/createscheduleinvoice.md: id: 99aa33eadf9e last_write_checksum: sha1:5266f93eec86e7cc62a4fd7611e01f10f416edc4 @@ -1096,78 +1232,94 @@ trackedFiles: id: 465bb2d9aab1 last_write_checksum: sha1:a4551c4c9b1ba66409a910d491152397457adcaa pristine_git_object: a50fa9ca91fb9eafd8eca65dfa453f3a56ffa89b + docs/models/createscheduleitembillingmethod2.md: + id: 5505803c36dc + last_write_checksum: sha1:1a7ac6e1caaee22e316a1b91bd7a2304327bcf3f + pristine_git_object: 7dfcbb63ed8ad3297377a26c3ef7e76ac499d695 + docs/models/createscheduleitemexpirydurationtype2.md: + id: 86033484dbc6 + last_write_checksum: sha1:1dd648c06da975d6450f7aa0a7fd488fa6cda3fc + pristine_git_object: 29096882280fda308cd911fc6c4bc49ba4983761 + docs/models/createscheduleitemondecrease2.md: + id: 2acd08beb71a + last_write_checksum: sha1:5443429d78c0a0880ee382c02f23f5ccad2527c1 + pristine_git_object: e48d393b819644a083d05aad0cec4c3782eb8b0a + docs/models/createscheduleitemonincrease2.md: + id: be6d625d4939 + last_write_checksum: sha1:8b08902cf92b8c94bfa4d764bc81fe79a2ff951b + pristine_git_object: 35d95a9d454ea4f43f87081800cd5eaa831c9d75 + docs/models/createscheduleitemplanitem2.md: + id: eae69f918d0e + last_write_checksum: sha1:9fb70b7fc07195263f628ad5619a67a82d01779b + pristine_git_object: 106adfea44d6251612a0b17e63452e74ad668a5b + docs/models/createscheduleitemprice2.md: + id: 8683b775aed8 + last_write_checksum: sha1:2d913ffffa02a1dd2db151cdfdca3a5af86cc894 + pristine_git_object: 855a775b622901d9079322410169abd4c56ea6ef docs/models/createscheduleitempriceinterval2.md: id: 068bd5764bce last_write_checksum: sha1:498275d15b787de04c157bb6dcf6a2cd399033f9 pristine_git_object: 1a9461f947a1aa3b2599ad4e7ad91cca4ee1f394 - docs/models/createscheduleondecrease2.md: - id: bd82733c0bb1 - last_write_checksum: sha1:fad188df5c98ea8afc2ee9469a394879f055737a - pristine_git_object: da48d2b573ea532b8f07a8c9bbecbb15a823e95b - docs/models/createscheduleonincrease2.md: - id: 7252ffa2a2fe - last_write_checksum: sha1:7b260df00d9c23045efc66d4ceb069ca62ef751c - pristine_git_object: 99a759e41bbd512fd12a044916b8fc02e60b0adb + docs/models/createscheduleitemproration2.md: + id: d246fe2cde0b + last_write_checksum: sha1:2b677f2c0cd4a0719ba0673fbc0f13cb744f63e5 + pristine_git_object: 5c30b5a0e428a72ac6e7618a789a2eadaff03e64 + docs/models/createscheduleitemreset2.md: + id: 07cb1163877f + last_write_checksum: sha1:8a43e403178ae72373e690a5f4839ddbfc922dbc + pristine_git_object: 8b9023bf973e00058eacce096aeabda3e133b478 + docs/models/createscheduleitemresetinterval2.md: + id: a2c075faeb63 + last_write_checksum: sha1:e8845bcffd3aa38bd94bb9626a054b5f3ddfb944 + pristine_git_object: c02f479e1ef1683255d45e07826b1b38e3eeeaf1 + docs/models/createscheduleitemrollover2.md: + id: 67c9df7ecef0 + last_write_checksum: sha1:8821cf5c6df695273cbfe19962e569e1b5d66958 + pristine_git_object: 82bb01b7ced9155b2c58fcd5912bcb688e4b5261 + docs/models/createscheduleitemtier2.md: + id: 55550925b9b0 + last_write_checksum: sha1:bf10ab9aa790c891abb061e4a5317848882ce56c + pristine_git_object: fabe990c35b86a99711b67b7a8bbd4a2cf49574b + docs/models/createscheduleitemtierbehavior2.md: + id: a82117c7a5ea + last_write_checksum: sha1:c92c1e5f0647e82e91a7e41f51c657704985aab0 + pristine_git_object: 133fa4b65b4f5e58f851c35b0e8fd2188c819f02 docs/models/createscheduleparams.md: id: 20f486e2b183 last_write_checksum: sha1:76df4c1427c0487d24d303708011cfadb9be8ffb pristine_git_object: 7237fb6f9fd5fcff4244db28ecf2f077858df728 docs/models/createscheduleplan2.md: id: a42f3ea41a0a - last_write_checksum: sha1:a89e01782168c74bc54f3be72e0b8f1e71cfcf24 - pristine_git_object: 0c81cb9e6642a8181ba1ae81cd0a4fc24a5a56e0 - docs/models/createscheduleplanitem2.md: - id: 2b3cb09fa813 - last_write_checksum: sha1:325361086fef8d0559cbc55813d544f092dad046 - pristine_git_object: f4f8afd797436d069fd88e7bfa9bb5e2327480c3 - docs/models/createscheduleprice2.md: - id: c3801269efeb - last_write_checksum: sha1:3da569cfaa8e3daf6fc3b3f20bd60e18a5dfe70a - pristine_git_object: f9a6c037b55c579d00768c368c3e68884a96f33c + last_write_checksum: sha1:1cf4d57882ce36f72c30c2681617462d33d507c7 + pristine_git_object: 1f1248cccea87d4e614f644962b2326a3e3e0963 + docs/models/createscheduleplanitemfilter2.md: + id: 424832b14a5b + last_write_checksum: sha1:788e1a5dd805a3670372671d987684608e078ba5 + pristine_git_object: 7c99005a5512ec78b3fd27fcebbb63f755d485a0 docs/models/createschedulepriceinterval2.md: id: 00765df9eaab last_write_checksum: sha1:a8d8aeca47a077667826af06949a237fd457bb6a pristine_git_object: 1e05140406e5b892611a0e99e19efe47887bdf90 - docs/models/createscheduleproration2.md: - id: 3ff576b2a1fb - last_write_checksum: sha1:5b5493c003ee0a9f4a96e681503cc3f8f9b9418c - pristine_git_object: 7069a2cd634ef1ada2e9669e36583afa9c4165f8 docs/models/createscheduleredirectmode.md: id: 689f929f921e last_write_checksum: sha1:296328336c7c1fa4a16a231d7792df5075c2bdce pristine_git_object: 76bdc019e78ef6d41470299d7f4b923568029179 + docs/models/createscheduleremoveitembillingmethod2.md: + id: dcf215154968 + last_write_checksum: sha1:c973ece987d8bd52e4c0e31d8c41578cd52db8e2 + pristine_git_object: 3222f632f32b968f2de05d32631d4150a7bcd6f9 docs/models/createschedulerequiredaction.md: id: 82018e476b97 last_write_checksum: sha1:03cd49c5ebebb116ccc00cd949cd7fab1940e803 pristine_git_object: 86831cbdde9c6deae049ff0db7eba5e67d48bd32 - docs/models/createschedulereset2.md: - id: feebf22e6c64 - last_write_checksum: sha1:96b444f09c86df862137199b39ae1633d1c101b6 - pristine_git_object: 1e6a10b7d31deec21969c70880e535150556df1e - docs/models/createscheduleresetinterval2.md: - id: c3721a746f30 - last_write_checksum: sha1:9d588520fefe2ec12b7b35ad15d83e26036338dd - pristine_git_object: 6da5164cfa7558527971676876aabe0f11eb8689 docs/models/createscheduleresponse.md: id: e5e52edbfe1d last_write_checksum: sha1:be9c666a5ef0aaaeafcc7a7e8f29c7b2ba401e71 pristine_git_object: 0177ef63c5cd5429e3326167b7140b661a965674 - docs/models/createschedulerollover2.md: - id: c2c1ecc33a23 - last_write_checksum: sha1:43f8381ad8fe4a598bc8f8795da4ac3cfea34775 - pristine_git_object: 3552c73a61af83c128ea03e8ce807d5cbabcfb54 docs/models/createschedulestatus.md: id: d4a23c68e243 last_write_checksum: sha1:b43589609c09d5b663feb75d0001a661ff39c5c2 pristine_git_object: 41df0965e5b139b6ee229d253f76557bb20d0145 - docs/models/createscheduletier2.md: - id: f7e60c0166fa - last_write_checksum: sha1:1283ec490092867f51d1408c49df3b5f5819dc3f - pristine_git_object: 4f6eea8a86d6497c6c535ecd45293f267131301c - docs/models/createscheduletierbehavior2.md: - id: 3c8ef0ecb3a4 - last_write_checksum: sha1:3681feeca4a0ac6ac0ad5cce1edc00814d71251c - pristine_git_object: d65d6bb2aa379ebcec5024ca959208b84ad15a96 docs/models/customer.md: id: 42ac97d31359 last_write_checksum: sha1:75b88c1c779312f5d7db5ac254614452863244ff @@ -1254,12 +1406,12 @@ trackedFiles: pristine_git_object: e93c4a0ddb2a284860c84aa39ef1a2d9a29fe919 docs/models/customerfeature.md: id: e11a0acefe5f - last_write_checksum: sha1:581d157d60440eb1422d1aeb94d8fb184be5982d - pristine_git_object: 9649f4d473f68f78ef54b61170388b95fb903a94 + last_write_checksum: sha1:366a0388d43a06a447d93c1eb61ec06dceb21979 + pristine_git_object: 8018c827dcc84ea8a03508bfc92dda3512f3daf8 docs/models/customerflagstype.md: id: 344522e4c604 - last_write_checksum: sha1:aa7f2fad97d746fa377eb63af23eee177efe47b9 - pristine_git_object: 3cda316c1c6606a7a3ca9969c9380ae88281a803 + last_write_checksum: sha1:5dedf2f5305497a55121a2faedef17496d91ea9e + pristine_git_object: e64eeeee5ad01ed1d162c119dc0ef1b9037ffe63 docs/models/customerinterval1.md: id: 3cd3e369fd1b last_write_checksum: sha1:f44717159fe65a8b24b664eab41f623808fe80dd @@ -1268,10 +1420,18 @@ trackedFiles: id: 598c09f11cb8 last_write_checksum: sha1:fea6844bf713444ea41a10a4fd9ef46f8d6bb9dd pristine_git_object: d78f46da0a27c49cbfad6aa271adf98d1c610462 + docs/models/customermodelmarkups.md: + id: 1cac7cdf22ee + last_write_checksum: sha1:270c11a5600787da507aedac71b03b4b230e6ffa + pristine_git_object: 28a3a172fa7b6daf101159ed75bea2c264cbcd91 docs/models/customeroverageallowed.md: id: 4e5bb7971cc9 last_write_checksum: sha1:b3ea6389e278ea933cc5f5973a9a13b8b8fb65bd pristine_git_object: d6ca938e0987cd3aaee05e99ddd1770a72efae1f + docs/models/customerprovidermarkups.md: + id: 6501c8ab0dd8 + last_write_checksum: sha1:69f41836738935653440ac25a42ba470800bc7e4 + pristine_git_object: 896eef95b2306a800d423732dd4c586a0350cff5 docs/models/customerpurchaselimit1.md: id: e8b2a5d22e06 last_write_checksum: sha1:4ac83c71a6a8b086e43f87f8c81fbdb5dc1b3ec3 @@ -1304,14 +1464,6 @@ trackedFiles: id: 8a97d4d05384 last_write_checksum: sha1:0efb4b38e5e855b2496b7485c3d857a4bf9e0ec3 pristine_git_object: 1e4a2666660e5c7810efaf971a0f07d6ceb125cb - docs/models/deduction1.md: - id: dd8575ca5e88 - last_write_checksum: sha1:90c08aa82e2ad76bd25a3c299cfd9db0b39be508 - pristine_git_object: 59c27fc8e9e6d98ff2795cb06f1efcbd3cc55556 - docs/models/deduction2.md: - id: 3718ef0a5a82 - last_write_checksum: sha1:42d195628cfc0c9d10539b327b3672686d4ca8ab - pristine_git_object: 17d8e73e1cc2c96f7ebf6d430e7a2dd93410df46 docs/models/deductions.md: id: 4c3443de5c70 last_write_checksum: sha1:0afaad258e1907949c028855d826f513926fc29b @@ -1458,12 +1610,12 @@ trackedFiles: pristine_git_object: 7da619a9e958f42269a6a8c54394f6eb6fa390c3 docs/models/flagtype1.md: id: a28ecd1eaf04 - last_write_checksum: sha1:6aea05cd73ef262ecf70a9ad76dce9259469c7bf - pristine_git_object: 6761a49bd75238535ecf53685c7122ec36118918 + last_write_checksum: sha1:86f0bf9f6b981b153d68b875ed877e57ff7cbe60 + pristine_git_object: 4c0af09f502a592dca66449232ae7bf6ceab01ad docs/models/flagtype2.md: id: 5beac8bbc73a - last_write_checksum: sha1:996772277138ae0ef807cc6544ce0918cf0c4329 - pristine_git_object: b59a4c86a1798536ce63d6f5c7c8479270c63221 + last_write_checksum: sha1:18e92058c5338c16edaed459c0a7d6c3e9dd9147 + pristine_git_object: 27e647d2b9a83f32913c07d76fa297a040bfa300 docs/models/freetrial.md: id: dc73eb37daef last_write_checksum: sha1:91215dab3a818bef14ec2d97d2f9fe332f29817e @@ -1526,16 +1678,16 @@ trackedFiles: pristine_git_object: 4a6dd933a23733b06a3ab2eaf2028e6e5ce45431 docs/models/getcustomerfeature.md: id: 65ee3d4b86ff - last_write_checksum: sha1:bdd92269ae4fc21f47f13f7ee19e81e3ae195a1a - pristine_git_object: 476f398f0265abc0fc6cfd776187a87921cb5cdd + last_write_checksum: sha1:1556ca4b5761698b06bbceeb27d171e140be0819 + pristine_git_object: a17319f63cfa7c28c3360631828beb85377bbd38 docs/models/getcustomerflags.md: id: 11d29a5797da last_write_checksum: sha1:2c8af11617993ce97ec09c72367420c5732ff5fb pristine_git_object: 51fd116f5e5e20b806d5dd9410b058e36b83d522 docs/models/getcustomerflagstype.md: id: 847cbac1db8c - last_write_checksum: sha1:ded81b58d01b3d1e41fe1451c31a38a4a3deeab4 - pristine_git_object: 4f402030b27fe7c9084e9032f75de14838de9d6f + last_write_checksum: sha1:642dde247b295523fd8f18b2c57f432f6654dbe0 + pristine_git_object: dd28d876b809f9f1299d3076023f4971646fadfc docs/models/getcustomerglobals.md: id: f4a52f28b640 last_write_checksum: sha1:fa7383045189c32179cdf592e4ca52971a341cad @@ -1552,6 +1704,10 @@ trackedFiles: id: ff655e902b31 last_write_checksum: sha1:305648b00287e5dc8e4b7204f61bc4c093835c8a pristine_git_object: 55540f9d003cd857e3f85079b32f342ea33b5d3a + docs/models/getcustomermodelmarkups.md: + id: f84850c03e6a + last_write_checksum: sha1:aea3c92ccb10adbfb4758fac1972b035181f9a92 + pristine_git_object: 960c9a5e89104ca0e096a5006c833bea00be13e5 docs/models/getcustomeroverageallowed.md: id: 3d70170f0375 last_write_checksum: sha1:fb5aed6477e6177f1d069b947ee4c2d84b7d9a51 @@ -1568,6 +1724,10 @@ trackedFiles: id: a08814f490ed last_write_checksum: sha1:dbed2798464378db6ad8d55159ed4d851a2c4384 pristine_git_object: 99c68888cd8b00b21d6dc2ee36e25d7c85bafb76 + docs/models/getcustomerprovidermarkups.md: + id: 7ddc2e847537 + last_write_checksum: sha1:e195fe9fb1879a1ef37c112c934bc69ce44539eb + pristine_git_object: a20d4134bc9d728381a422156ffa5717e08ea3ba docs/models/getcustomerpurchase.md: id: dfc2dae14e5a last_write_checksum: sha1:b4667aabdbf6c18dc2909f25b9cdf11203d09dd3 @@ -1662,8 +1822,8 @@ trackedFiles: pristine_git_object: 829882d267976886364436e4844c2efb99a2fb26 docs/models/getentityfeature.md: id: 70f61caa19f2 - last_write_checksum: sha1:6147805cbc0333d17b23cdbada931979c1d92e5e - pristine_git_object: 5097fc2bdea76ead52711b2703c4f321765a788b + last_write_checksum: sha1:e4e15b4c323ac2b919a4455720dfecd1cb8f6f79 + pristine_git_object: e9955db5a7f7dd302947e41f7c46c6366a3cda79 docs/models/getentityflags.md: id: a85e6b11a67e last_write_checksum: sha1:7ec0dc355388d6afb58e4d250e8d2fb474eaca75 @@ -1676,6 +1836,10 @@ trackedFiles: id: 498d83441f95 last_write_checksum: sha1:7b40d2c3a7c0ce50cac2e5f7932b6be60efe3db7 pristine_git_object: 806923416afaf780d0d321e66be69c549f0816b3 + docs/models/getentitymodelmarkups.md: + id: edbfd93c5b8a + last_write_checksum: sha1:eb2487973e6a1d1cf32875459137262f97c1f2aa + pristine_git_object: de4e76aa7165c07c23f2af81e874a02a8d700419 docs/models/getentityoverageallowed.md: id: aee5e34dd3bb last_write_checksum: sha1:e2b8505b47273a9314d16a08d9964e11663dcc79 @@ -1688,6 +1852,10 @@ trackedFiles: id: ef982b783d62 last_write_checksum: sha1:b67addc1f8fa57a645d0755a0f5e78f59c76115a pristine_git_object: 92ec35902d94de56a0d513201d6eb1d7b5f31f64 + docs/models/getentityprovidermarkups.md: + id: 7294562fa966 + last_write_checksum: sha1:1ab27c783b792f5fb17cc377edfd7a6a62792790 + pristine_git_object: adafb53c10da2548f789b617bf5f14cd2aec12b3 docs/models/getentitypurchase.md: id: ce7162950983 last_write_checksum: sha1:0f6fd0ae4a19843a62ab85a2b9d81dc6494f95bc @@ -1722,8 +1890,8 @@ trackedFiles: pristine_git_object: 8ca9a3174d4b6b20fb61a94c7fd3aca1bed9f09e docs/models/getentitytype.md: id: 18fa9d2e3acf - last_write_checksum: sha1:815cb6238ce71a6e2aa917ffa4962115e9ae501c - pristine_git_object: 7e2b494a58a6995cc91f8eb4587df9825bcef0f4 + last_write_checksum: sha1:bc6b1e11bb17248bdde17e4a29ddb3d358afe9c8 + pristine_git_object: 696ec63aaf0a2fbab85b7fe0b91c66ffca62fc58 docs/models/getentityusagealert.md: id: 0f1780b25c03 last_write_checksum: sha1:baea45145a2738422d7730d7953ef54f56b15042 @@ -1740,18 +1908,26 @@ trackedFiles: id: cc1ec1eea4a9 last_write_checksum: sha1:321b8b2f5d06f949ea9f242dd04f8b60be1d6161 pristine_git_object: a90f9e6e6c0213a7fef6606dd63585927cdb61bf + docs/models/getfeaturemodelmarkups.md: + id: 15c969c8f4b3 + last_write_checksum: sha1:a4f7f3bb475ef46c3a12861a2d960aac6532a48c + pristine_git_object: e974cd0d565d2035d0edf6ec65f2365d142bd82d docs/models/getfeatureparams.md: id: 6bd7c64da73a last_write_checksum: sha1:dd9699be11af0e01ed42085e36bd3b12ce584f40 pristine_git_object: d048f7f71cae67d01f82ad0b4d3eb925de6188ee + docs/models/getfeatureprovidermarkups.md: + id: 69ac9756fcb8 + last_write_checksum: sha1:cebcf2a4424023028bfee600a270414b3f5cdc01 + pristine_git_object: 09b68fe2f86d5cd95ec359be06b550dbb25196f9 docs/models/getfeatureresponse.md: id: abfdf9f7ecd6 - last_write_checksum: sha1:e1eb3f72f170953e07588b7c7765e7d8fceb12a9 - pristine_git_object: 517a8f8299a7fd6c192d7a0e1ee842ea6aacc804 + last_write_checksum: sha1:1fadb985e6f62d4f65fea8c42617b335137f6e2f + pristine_git_object: d87528405ed049a662f276f7e3da42f8c3edddee docs/models/getfeaturetype.md: id: c54f4a256601 - last_write_checksum: sha1:b4139d96a66706eab122e9505e68fc158cb1a4c0 - pristine_git_object: b8894ad0c26ca43ce443ef4678ef6ebe6bbf9a8e + last_write_checksum: sha1:0bd70e0e25408d94e13c6204a0f4a447f315aa16 + pristine_git_object: f5f28a920ccbcc244822b0994dcf89419eef72f7 docs/models/getorcreatecustomerautotopup.md: id: 2beff692c107 last_write_checksum: sha1:980d9d480f667e5f75ee005128a4023488a139bd @@ -1854,8 +2030,8 @@ trackedFiles: pristine_git_object: 6728190d3751f3fccc8e31d333b11846d5f6e489 docs/models/getplanitemprice.md: id: 1003fb6321bc - last_write_checksum: sha1:961177bc1ce7e232d40a9447c00d07390057dee5 - pristine_git_object: ff12c919f27e814cb6921e0c651e6eb7f6212d28 + last_write_checksum: sha1:76992a6d6b84bcaafa18b31cf629d2d340b920c1 + pristine_git_object: 0c83c64deb665b55c1cb0e6e3071ffbdccd20c85 docs/models/getplanonend.md: id: 809d351ef357 last_write_checksum: sha1:da191f3d8dd5098d5fa4d55f6b3b0044da4b6eec @@ -1900,14 +2076,22 @@ trackedFiles: id: e934416f9b0a last_write_checksum: sha1:b366acc3392ec751e92a8e07dec9074a03a98297 pristine_git_object: 8b1eb7d78f8b265ab13e1822740124794e4e4ff3 + docs/models/getplantier.md: + id: 25c80d2e311a + last_write_checksum: sha1:8524b95b281ef267c176fca3a7d20d116c9b144e + pristine_git_object: e11908292343e86ace341d559859efbf5a6ceb27 docs/models/getplantierbehavior.md: id: 43d70b082849 last_write_checksum: sha1:498f80b2248b63d64e57495ab64dd7c82941f8e3 pristine_git_object: 512006d976e36f5a5bd72fb4520bbaf7b1696747 + docs/models/getplanto.md: + id: 4370b94fa94c + last_write_checksum: sha1:8a6c9aa8e0f1b550e9d075188dac724bf555adc3 + pristine_git_object: 954d44b1ff659f883d67c701b0eb00f8e8d99419 docs/models/getplantype.md: id: afbdf26f0f4b - last_write_checksum: sha1:6d5dfd04c5d95c235f56eb23e31f497c9ed91247 - pristine_git_object: 7390b496108568e19aba42d36c5c3b508e3d42b3 + last_write_checksum: sha1:7ad6112fd1293a345a41c4d3c26d3377347a13d7 + pristine_git_object: aebe5ef3a5662e0142da7b58c54721683afcce1f docs/models/getrevenuecatkeysapp.md: id: b47525a2f9de last_write_checksum: sha1:1de286cbb4c475583313a05f5d89c8cdc90fcbf1 @@ -1994,8 +2178,8 @@ trackedFiles: pristine_git_object: 9c87d7fb576ddebfd331368c52799c7f92eee799 docs/models/listcustomersfeature.md: id: 0d1d6a29245e - last_write_checksum: sha1:c2bc21d88712f18bbd4fbc5fa7f2085045a379b2 - pristine_git_object: fbb7ca7bffeab6a1049152620dfb4bc29843d982 + last_write_checksum: sha1:fad41539b9c903bbef7f55a8aa7d7f7a029ee1b3 + pristine_git_object: 54383f09919f56d75cbf4af2036bb0acdc1c1e4d docs/models/listcustomersflags.md: id: a1097040f340 last_write_checksum: sha1:68807d35848600e3d34a545c654173c6baf80bf3 @@ -2016,6 +2200,10 @@ trackedFiles: id: d71dda8a696c last_write_checksum: sha1:3b092f467531090a0d01d4ff4eac7e27bbb2ff72 pristine_git_object: 929205a42d60502b40453d3807c1f46b1fd1f1a0 + docs/models/listcustomersmodelmarkups.md: + id: 672a5453cef9 + last_write_checksum: sha1:fcd2aa8f7ea89f2536256e9215fbbe70ff1ad6f5 + pristine_git_object: 01fac98ed3c5a430c86fd28f49e9e93df42a872e docs/models/listcustomersoverageallowed.md: id: 4f3335e3d0b0 last_write_checksum: sha1:a1e1ccbdb818b299d51c3619696799df0cd3883b @@ -2036,6 +2224,10 @@ trackedFiles: id: 2211167d80bc last_write_checksum: sha1:97fd5625f7cef96f3bdcbd9949e43a00cf72e25d pristine_git_object: 27007645d995e6fc194480b9088bb2f28972829f + docs/models/listcustomersprovidermarkups.md: + id: 6e60b56746fa + last_write_checksum: sha1:15617139a6e3cf1dd7542f13302240809163c359 + pristine_git_object: 5f4504eef952f0ac6c909da10f9e0266ff698005 docs/models/listcustomerspurchase.md: id: b7fa1c1d2bae last_write_checksum: sha1:2f27d0e7f8c80567c773e599ee27d365a310acfa @@ -2094,8 +2286,8 @@ trackedFiles: pristine_git_object: db29404ecddc3fbeeb37ae4d1ae346cb4045fa82 docs/models/listcustomerstype.md: id: 431485f373da - last_write_checksum: sha1:38c1bf892ba103e25ee8c0bda7b322c7b1c97150 - pristine_git_object: 408f48dfb01ee2b3f69e4d0486f3f6d5ef3626f5 + last_write_checksum: sha1:5a37dfb287ca9ddc909e96de813af41a10a34cd9 + pristine_git_object: eecf0d7075347ea8d9f8d44fbc8624bccf415bc0 docs/models/listcustomersusagealert.md: id: 3f57d100be4d last_write_checksum: sha1:484ea54ace981ee6624eaf658d28f1198526ac51 @@ -2122,8 +2314,8 @@ trackedFiles: pristine_git_object: a9ebbeb7bf14c9a1598bfbd83163dde521235e1e docs/models/listentitiesfeature.md: id: 08603dec2c1f - last_write_checksum: sha1:071d7f8011d472df7baf957f3aded44ad9b5402a - pristine_git_object: e658feb39a6bac18dace0aabd2a318549defd9b3 + last_write_checksum: sha1:3ce6a9f68f81d6457b7e0e4ba0f0801e134cce4e + pristine_git_object: 3e70164a9cc23a9f096fb58350ae47314bac1ef2 docs/models/listentitiesflags.md: id: 4482b1a76cdc last_write_checksum: sha1:53d84ccb81a916557804cd4896a7e6f9a308408c @@ -2140,6 +2332,10 @@ trackedFiles: id: 311c07fcabfe last_write_checksum: sha1:f1c7c6a5263ec2decc46202578f8733e79f01350 pristine_git_object: eb29b6cedcda9746335512a3b0ab535bb973ca93 + docs/models/listentitiesmodelmarkups.md: + id: b5b2202e2f35 + last_write_checksum: sha1:dc0f7d23c6ebe9df7885c9f504b187ce2cd9c4aa + pristine_git_object: 724a9cabed2903f33e30a27456cb8c36513752c9 docs/models/listentitiesoverageallowed.md: id: 60065f058956 last_write_checksum: sha1:4a4d739998c3fa5511575e24fc516b43112b1de0 @@ -2160,6 +2356,10 @@ trackedFiles: id: 323ea4706190 last_write_checksum: sha1:585ed952e1a9f0a9d0b59a57039bdadd697174e3 pristine_git_object: ae1dba57381f5dcbe29227e11dc92a6abdbb1f99 + docs/models/listentitiesprovidermarkups.md: + id: ff0e4892569e + last_write_checksum: sha1:35780a6e7bccfa71e9365865f6be1243453f46d6 + pristine_git_object: 3c8175a5e502ed2fe21e90f02b38aed7944315ce docs/models/listentitiespurchase.md: id: 14e60687626b last_write_checksum: sha1:ea60115b50730c92c1b4a8491f71198fe8aa05ed @@ -2198,8 +2398,8 @@ trackedFiles: pristine_git_object: 537cb38576074d06525cf4146a3a762afaffa28f docs/models/listentitiestype.md: id: 55bb8d5169fc - last_write_checksum: sha1:42513559b2ddd6b0e8bafa2797c6dad5e3e7f13b - pristine_git_object: 013aa1b44d31a682eb2a6089bef57376a957319f + last_write_checksum: sha1:5765887da718e4fc7e93520dd0b4f9b17a897360 + pristine_git_object: ca083dbf6f809b3eb46e3e5f4015cd5f2cca4822 docs/models/listentitiesusagealert.md: id: bb187648e55c last_write_checksum: sha1:7f35db949af485f0e6efda12b513efdd0f37bcaf @@ -2250,8 +2450,16 @@ trackedFiles: pristine_git_object: 6e28aa4d2a1eb179bba0563c3de8a4813a654570 docs/models/listfeatureslist.md: id: dce9061ad49f - last_write_checksum: sha1:fb26cc61b7013357641eb948e1ded9d6415ce961 - pristine_git_object: 69b4e20148024e870368f61664e9cb3516e0343f + last_write_checksum: sha1:a3e44ce1a46a6816c36a560404c58ae1adcafe08 + pristine_git_object: 04d4c5e2f6c46e623113c7650743e0dcf2b637f9 + docs/models/listfeaturesmodelmarkups.md: + id: 9e3fcb79a6a9 + last_write_checksum: sha1:c835a045ddb0bef706450f81deaeaa907d4937ed + pristine_git_object: 2adebe122192aee67ca4c9671fad63d796c62f16 + docs/models/listfeaturesprovidermarkups.md: + id: 9253bb1e7c8d + last_write_checksum: sha1:e3f3be906fbd2fa27819761dd0a09ee54ad6d12f + pristine_git_object: 48fd71de001740b8975ba04da718339ca7a6539c docs/models/listfeaturesrequest.md: id: 9fee969e1917 last_write_checksum: sha1:48c33f0f7f4d5901ec3fe3f3c80b04742d8d7e4d @@ -2262,8 +2470,8 @@ trackedFiles: pristine_git_object: 6e6cfa9219fa9f0b24adc850386f46f8b6fbb0a3 docs/models/listfeaturestype.md: id: 38d27c59e570 - last_write_checksum: sha1:456db91e3466968ec933e354505445e480244a94 - pristine_git_object: 1d0e84005191feeaf5423baf5c29ebbe47f149ea + last_write_checksum: sha1:c2f66d55c98c6c50b824759bd57ad329e26c96ae + pristine_git_object: 525b625bafc44066040fe58b88adc8cbd6cad16e docs/models/listplansattachaction.md: id: ebea6c110ad3 last_write_checksum: sha1:5a53c97c8e2f16407c174de56d2a17a1897d3fff @@ -2322,8 +2530,8 @@ trackedFiles: pristine_git_object: fed159e8337df81138a81885b73bc60eb9b2268a docs/models/listplansitemprice.md: id: afd054e4c901 - last_write_checksum: sha1:a49134a1ace751a2c24799101959f3e86f900501 - pristine_git_object: 714e47a457c8f29a67c4d007b9fd0aafd6627f86 + last_write_checksum: sha1:d3ccdeeee4ef516942a626b16ea918fe3649b2e1 + pristine_git_object: e2405c176ca17250f533bad0c44e04afa4eb40a7 docs/models/listplanslist.md: id: 6e9f463afde9 last_write_checksum: sha1:12a22f7a98b85bcf87cde5768864e70027d376ad @@ -2372,14 +2580,22 @@ trackedFiles: id: 0e36930afc34 last_write_checksum: sha1:9d45881741f8a142247a4006d141aba297608c27 pristine_git_object: 128b5ab6310da37fb03fe81aaf1af862329cea33 + docs/models/listplanstier.md: + id: 349f4154ddda + last_write_checksum: sha1:02a9ffefba8dd50008af28527aa7ef566945b8e8 + pristine_git_object: 9f9094bbe176479f3e546cd615bf092c0f52fccf docs/models/listplanstierbehavior.md: id: f910370a1711 last_write_checksum: sha1:6f4e9ec42f871e049b0b2932b24f1c148ff42d64 pristine_git_object: c88a32bdd79e74b7a2f348248139d6b3f32df974 + docs/models/listplansto.md: + id: f5c4d5fd869e + last_write_checksum: sha1:8bbc02c32db22255d8cc9b70f3e0a809ea5e1553 + pristine_git_object: 032fa3f4f627c14fe113d73ce7f805b02f5a2e5a docs/models/listplanstype.md: id: c265fc1d3752 - last_write_checksum: sha1:ee4d791c463a7a530becba66e8f576b75b7ba217 - pristine_git_object: a105ee9d7d196ff96d7d56e990860f75cde637f3 + last_write_checksum: sha1:f4ffdcd9b9ad50adff2b1e180f85bf84e6b1d68e + pristine_git_object: ed6345e784779fd62d30a6913295cd1b24a26e40 docs/models/multiattachattachdiscount.md: id: 84b9e0a4d680 last_write_checksum: sha1:7f6655964faa1676f26c26520d23e42cd2eb8af6 @@ -2470,8 +2686,8 @@ trackedFiles: pristine_git_object: e494280b32fed45a642f2f11d135a6da8461d71f docs/models/multiattachprice.md: id: d4670fd30848 - last_write_checksum: sha1:99a909160bcfb0b16835ca5824969855fe05d2b2 - pristine_git_object: f5035ac6fda1d63df18f9a22fdd0f565e9778627 + last_write_checksum: sha1:6bb348816b128e5bf03738b053bedf87e3fc6338 + pristine_git_object: 96d9cd1049a96ec6e69dd4537c373b561723bb4f docs/models/multiattachpriceinterval.md: id: 58fbae5e506d last_write_checksum: sha1:573cc1cfafc85af26cd3bbf3143fac03a626b8d7 @@ -2594,8 +2810,8 @@ trackedFiles: pristine_git_object: aed40400dc4b6c589ef8673f84dba763aa7588b6 docs/models/planitemprice.md: id: edd9119a1319 - last_write_checksum: sha1:a12dc65062ba04f149fdb62388c802cf61d39401 - pristine_git_object: d9c328971ffd3eb2ab954794bff92e3a0374d646 + last_write_checksum: sha1:004313fd7f7c8b638c04520720ca697bfc99b5fa + pristine_git_object: 0578ca15e111a177b84a4f162294644d59da4236 docs/models/planprice.md: id: 70699ad0c942 last_write_checksum: sha1:c883ecfef57751ded74a650fd1fc91bf9d87ed4c @@ -2628,14 +2844,22 @@ trackedFiles: id: 1b32a1788a18 last_write_checksum: sha1:74508405c05c6e93bbe1583facd1895c813f18f0 pristine_git_object: 65802bc0e9baadd827bc460a5ad655acc1298888 + docs/models/plantier.md: + id: 6c3492fd8bbf + last_write_checksum: sha1:95ab775638e41967324fcc2b28e412109b91cd99 + pristine_git_object: 22a0e9f1b88f73474f59341d3f7efaa08209a17b docs/models/plantierbehavior.md: id: eef000779cfa last_write_checksum: sha1:53e33ecac045604feabfab5d02a5caebf791f03e pristine_git_object: 9ae9f32d44709187aa8d6805f92489ab7934b60c + docs/models/planto.md: + id: 642e67b335e5 + last_write_checksum: sha1:699b39d870e41c1926ca1f043c3273b00c8b80c0 + pristine_git_object: 5c73883ba8a35a3b1c168ea4f8b6b66cdfdd0a86 docs/models/plantype.md: id: 4c86df78e60a - last_write_checksum: sha1:5246ebf75c009119cbfcd501cdeb659e9f3c6786 - pristine_git_object: 5add1a3cab3d01077452a4df8d9037c6279da319 + last_write_checksum: sha1:5ec570e5d4e11021a86eb666a1c698b34182780b + pristine_git_object: 747f8f613890a8d8c261f10663bdabece02494f3 docs/models/preview1.md: id: 203e34d3c393 last_write_checksum: sha1:116b6aa31a514f220d270164fdc3572eacb16be8 @@ -2666,8 +2890,8 @@ trackedFiles: pristine_git_object: ef29059186c7528a078d279d9fb465f572083227 docs/models/previewattachadditemprice.md: id: 6da37e5500b4 - last_write_checksum: sha1:3c0e09b1e3515f307e1e1d78032c24211c61e40c - pristine_git_object: 8a42d34630e016728d66f2b0123dbc1a0ab93cfc + last_write_checksum: sha1:b1be7a9f904d38982bb78801dc66bb3bfa65240f + pristine_git_object: 1340d307e26ceba71318c6cacbb9edd5d9f6732c docs/models/previewattachadditempriceinterval.md: id: b1d1a8c9243a last_write_checksum: sha1:3298685ba7101db4adfeb42f824c1bfd576cfcb4 @@ -2722,8 +2946,8 @@ trackedFiles: pristine_git_object: 0cf337f8ba27ffbb763473c34c1ccbc5a80c7863 docs/models/previewattachcustomize.md: id: dd921922e55d - last_write_checksum: sha1:27806a84bce815d3d141450e7a72007ea6ae7acd - pristine_git_object: f25e31787b5a48fdb939faff476d8d5e5af48d69 + last_write_checksum: sha1:4d8396261be4cdbfae116cff897a463cfd7b8c7c + pristine_git_object: c0539f27fdda66440e5da8839348ac980e5eb153 docs/models/previewattachcustomlineitem.md: id: fb31b2febbbe last_write_checksum: sha1:9ae0c9cd0145f3c867faa845a1ac73bc72c69606 @@ -2756,6 +2980,18 @@ trackedFiles: id: 6cac4cb0ca48 last_write_checksum: sha1:697fd3000cdf5febf7a78846223b52fa115714aa pristine_git_object: 27a8771eb5b6152b0bba9da9427a1f94104db92f + docs/models/previewattachintervalremoveitemenum1.md: + id: 2038107eced6 + last_write_checksum: sha1:4b8968ccaa12d4d2f7a30e5c2107e0fcdcccbe9d + pristine_git_object: e155fd0d2a7502dc4ddcee627b5fb05484213297 + docs/models/previewattachintervalremoveitemenum2.md: + id: 9b9aec0b1c40 + last_write_checksum: sha1:14993f7c46d58b869af8ea79cb60d266301c3dc3 + pristine_git_object: 998a86bb2035bf568a2c8d01414d0254cd55737b + docs/models/previewattachintervalunion.md: + id: b6dc09bd8ec3 + last_write_checksum: sha1:d83da43303071c09342d36dddbe927c61903f1c0 + pristine_git_object: e478df58deb9814a107a4d283e3fd5b684590e33 docs/models/previewattachinvoicecredits.md: id: c34e1807d639 last_write_checksum: sha1:4e69690127bd3a31bfaddb83d282d5ca33dda35f @@ -2786,8 +3022,8 @@ trackedFiles: pristine_git_object: 2ec00b157f856fc85281660212ba7195d78aac8a docs/models/previewattachitemprice.md: id: 7b4a988d6e91 - last_write_checksum: sha1:438b50486c3cba44603d05b94adfdc10b8ed53a2 - pristine_git_object: 0019d2333ad20579e30f32ac944a9ff2f026e6a0 + last_write_checksum: sha1:539cdb6731e181ddcddbc8968b0f431f223aa769 + pristine_git_object: 231eb14dce52b2f8bb0e3adbc72afb05daa31cd2 docs/models/previewattachitempriceinterval.md: id: 96eeb2602066 last_write_checksum: sha1:fbe0200ede8f1c6acedfc320b2422a73c2b966b6 @@ -2862,8 +3098,8 @@ trackedFiles: pristine_git_object: b2059bb1254fade50c96ecc45a5f6aa92b41b55e docs/models/previewattachplanitemfilter.md: id: 9b150cfddab6 - last_write_checksum: sha1:60f113b637ae5e532fe4eb259252d8e57afdd587 - pristine_git_object: 4193d60545c8eec67353273047d8802fd4aa2759 + last_write_checksum: sha1:8d40c5ce67bf23086249675a4b767b98bc608eea + pristine_git_object: eef2f5cb58b86b688a4e69a64db8429f357b381d docs/models/previewattachplanschedule.md: id: 487cbb8bd9dc last_write_checksum: sha1:6b50ca928452851bd01e971838cceab1eb76b8ba @@ -2884,10 +3120,6 @@ trackedFiles: id: 8f8d98f7e34e last_write_checksum: sha1:3cd7204b2010ac51e2bb33bb246099198f1785cd pristine_git_object: 3adf87dc780b3963f9693bcf08444de0b7d9b9a1 - docs/models/previewattachremoveiteminterval.md: - id: 8fc4963c1b48 - last_write_checksum: sha1:84f26ffc6d64988fb9970b0245a597df3d4752f6 - pristine_git_object: 5996fa7a115079f3b03f132fd6342c1765eea641 docs/models/previewattachresponse.md: id: a678e8dbf94c last_write_checksum: sha1:a996de487bc35e975c8ae33a1849250fac820397 @@ -3042,8 +3274,8 @@ trackedFiles: pristine_git_object: dc590613d56850027ff04b4b74beb95f3700c965 docs/models/previewmultiattachprice.md: id: 368b3f70488a - last_write_checksum: sha1:05d632a1779f729991741d6dc5cb0b5c8121cd0d - pristine_git_object: 3e020fd0292ca357afe2fc1639370799b060c530 + last_write_checksum: sha1:a3090924d1b853e3fd16b0c5845ab9df18b3759d + pristine_git_object: fac397806dd9dc17d18ad959d087e7882d190a59 docs/models/previewmultiattachpriceinterval.md: id: 2b2601649b6d last_write_checksum: sha1:c2806dec52c16c53d5b9073cf810a42b30a854a8 @@ -3134,8 +3366,8 @@ trackedFiles: pristine_git_object: e672ffa222b1382c0d4fc9220806f47980515a46 docs/models/previewupdateadditemprice.md: id: 8fff2fa01392 - last_write_checksum: sha1:8233f20c6cdb8a0d3fd10ff9fa5751f7731cfa1a - pristine_git_object: a8a406d83de84e52e203a48d9433d181454eb11a + last_write_checksum: sha1:4e678aefac825a1262c54d41a6d2ac7985be7570 + pristine_git_object: 0cb5a5f14f3bd44d96c3b928d7d00f7179f32fee docs/models/previewupdateadditempriceinterval.md: id: 7448988b0895 last_write_checksum: sha1:410c644a53bb8e50a91964ab2d78be4ad424e282 @@ -3182,8 +3414,8 @@ trackedFiles: pristine_git_object: 41e8d142a8eb87255d707fd3a8d6adde82058735 docs/models/previewupdatecustomize.md: id: f4f7d5f4d0a3 - last_write_checksum: sha1:1b1c6aaad3eb5bfd1c1cbea1984b6107298b9c32 - pristine_git_object: c0d1278e718fe1a943c665af67d9138cd2632a37 + last_write_checksum: sha1:e4c84d16d7e4ced65378558283408f44cd2332b6 + pristine_git_object: 616eb32fecc911996fed446a66ceaec265128c04 docs/models/previewupdatediscount.md: id: 0236831b0434 last_write_checksum: sha1:a714b6ae6d0d60d9fdcb50240e4053362b80e9be @@ -3212,6 +3444,18 @@ trackedFiles: id: 76ae479acaa8 last_write_checksum: sha1:ac92bcc9ccc296703d0c839fd5c68bc59c00bd12 pristine_git_object: aaee40ab3b3c15a4bda2ebb4c611518c41395bbf + docs/models/previewupdateintervalremoveitemenum1.md: + id: 8d810a81e3b1 + last_write_checksum: sha1:a35d8e4c5ccb8d4564a5aae46e87c704fa5b2d0e + pristine_git_object: fc83b25fbc9d77d5541f654779a6d32e3fd3824f + docs/models/previewupdateintervalremoveitemenum2.md: + id: 8f4726b895b4 + last_write_checksum: sha1:9d3c023b68876cdda8f235a587ceeee16f035e89 + pristine_git_object: 0f86b64405575816a6f9ad8fe9f1d0b9d2c10e12 + docs/models/previewupdateintervalunion.md: + id: cc97ee066f93 + last_write_checksum: sha1:69c7bfbd0150645711d29220c9172d8fc31fdb52 + pristine_git_object: e69a67c00ce9347d99a12a39d9d3f47d94e70905 docs/models/previewupdateinvoicecredits.md: id: 7288053510ca last_write_checksum: sha1:2209f3adfa5de7934fa8e96d0cce8d402e49d469 @@ -3242,8 +3486,8 @@ trackedFiles: pristine_git_object: fbc3ee59a9b426fa662e196002ef2f13bdbce69e docs/models/previewupdateitemprice.md: id: f033f2f9e545 - last_write_checksum: sha1:9135dc89bae9847d2ce5d111032ff93d6f4c32be - pristine_git_object: 87a9a0e81e40c752c2dfaf87cb041af40e80a736 + last_write_checksum: sha1:8b126af69d1e8a15a49d3e48ad6af459a165cb61 + pristine_git_object: 3afbcb6c18b599b844b15a9882b660da0dbdf53d docs/models/previewupdateitempriceinterval.md: id: 5317a2333cd5 last_write_checksum: sha1:9439782c8faf5679e6dc27f749dae7d73f1fb9d7 @@ -3318,8 +3562,8 @@ trackedFiles: pristine_git_object: 8205ef9d1edb00bf1952de3b7fd4c0e428b8337d docs/models/previewupdateplanitemfilter.md: id: f5125fd22519 - last_write_checksum: sha1:24e1f713178fa8569f008b12f221a9d1a81e1c7a - pristine_git_object: e505171ef79d929a8811006c5981b12b6af81fda + last_write_checksum: sha1:1901c57a6d197dc798367367d9c277a412beb860 + pristine_git_object: 798cbbb4343a0e8015df517960d7c15122a3982f docs/models/previewupdatepriceinterval.md: id: 300f908b2503 last_write_checksum: sha1:c571ca192d0bb1b7b26cf6f450c1051aca1d9935 @@ -3340,10 +3584,6 @@ trackedFiles: id: 6bd47303f043 last_write_checksum: sha1:533643c31d0764eb75b24a8a6975e10dd6a4e453 pristine_git_object: 97bf2d65dedf0446e10964f8a62cd9cd8189d4b3 - docs/models/previewupdateremoveiteminterval.md: - id: 0e8a2e33ee58 - last_write_checksum: sha1:eb72ef5d67f023b61d94a1cbe45063a70a26e24f - pristine_git_object: 7c3b1667ea51474e154dddd880189b55a48f4c3d docs/models/previewupdateresponse.md: id: 4a657c603c51 last_write_checksum: sha1:c523ef42c895e2c84c460599a19e66c7963273f0 @@ -3498,8 +3738,8 @@ trackedFiles: pristine_git_object: 3504c1bde6fa67e4add878d37bfcfc8c7c0d9a61 docs/models/setuppaymentadditemprice.md: id: ecda07db389c - last_write_checksum: sha1:58594a8407648c858e9400b8943ca1ef625b3427 - pristine_git_object: 7f547a2b8c25247a3ee1d9fedb95d533a3dfe0dd + last_write_checksum: sha1:eac6b350c3c7445f17b5a6637163099c2a588d05 + pristine_git_object: e5ed6d202430ff4f793e4fc75da3acfc807566cb docs/models/setuppaymentadditempriceinterval.md: id: 9453cc6f1f31 last_write_checksum: sha1:ffd9971b16f3cfa7b75e6715baf1124095bd0342 @@ -3550,8 +3790,8 @@ trackedFiles: pristine_git_object: 4f47f9305eb714597c85dad9a90548975cb89370 docs/models/setuppaymentcustomize.md: id: a4431aa0e152 - last_write_checksum: sha1:739d12674560e19aef807c21b7e80cafe7190867 - pristine_git_object: ee1f85ae0c1e8d32905de2a2a6e422ead80084a7 + last_write_checksum: sha1:61cae9a727794bd8cdaeab2aafef5243a050bfd3 + pristine_git_object: 80a21ac9e8dafdacbb3f2c2477d4a4458b742378 docs/models/setuppaymentcustomlineitem.md: id: dc000338e2bd last_write_checksum: sha1:40bdc935299ecb52884fa5b057629c25b71728b1 @@ -3572,6 +3812,18 @@ trackedFiles: id: 3c1e725a7564 last_write_checksum: sha1:822c35950651414cffcfbf989054afc107a8de68 pristine_git_object: e46150aa6597ad09d02461b1438d42eb20ad44cf + docs/models/setuppaymentintervalremoveitemenum1.md: + id: bbb4a6f93050 + last_write_checksum: sha1:e59d534fc1807e34db0e8089c1c1e9e0b2bdb02c + pristine_git_object: 374d06f477172c381a50150083d1b70c533feaf5 + docs/models/setuppaymentintervalremoveitemenum2.md: + id: 8d7bd09c8e4b + last_write_checksum: sha1:dbae1f99936caed1b72d3ab89423f932864dd901 + pristine_git_object: 2579cb7fa2d86feb05e376eaeedae90a4d585730 + docs/models/setuppaymentintervalunion.md: + id: 815f0e91208c + last_write_checksum: sha1:bc50ed390d368ede945ef03af7fd60eb377c13fd + pristine_git_object: 35a9940bad4927af64537f5a7b4cfe721a3fc4cc docs/models/setuppaymentitembillingmethod.md: id: 4295c22bd419 last_write_checksum: sha1:3afd4675a1efea37eb21b3b82eadb4ae11109c18 @@ -3594,8 +3846,8 @@ trackedFiles: pristine_git_object: e8bd0caccd80b02a4be10d2a9d9840a5fb64523d docs/models/setuppaymentitemprice.md: id: b0df866e6f43 - last_write_checksum: sha1:8a4d41f83d2a53c3d533265726bcf7dfa7cdd74d - pristine_git_object: 0a4bbe435f68a952466388ee00cc0942235e7ed6 + last_write_checksum: sha1:0297b11d1941f1bbc4718dfc8ff37e1339f4e08b + pristine_git_object: fcceb4b831b4c86806811732f653daf285e278fa docs/models/setuppaymentitempriceinterval.md: id: 6337a430ab96 last_write_checksum: sha1:b663d2d0f2d126f86d87fabbc537e335d050c6c3 @@ -3638,8 +3890,8 @@ trackedFiles: pristine_git_object: 1794f35da19a56d2ab70ddb573b4e940f11798a4 docs/models/setuppaymentplanitemfilter.md: id: 7c5b09498e12 - last_write_checksum: sha1:fc27daa749036af8a312122b3249c513e2a83b5e - pristine_git_object: e3cdda238114d48d578a697583731ea27d07531b + last_write_checksum: sha1:8f3a81fa144dddde2f38ce3bca67e12aacef528e + pristine_git_object: bccec51001f8417efec8d5fbaf92597f93038267 docs/models/setuppaymentpriceinterval.md: id: 6dd9488b58f0 last_write_checksum: sha1:52decddd27ae126eef3b0c7c92d9fc981188335a @@ -3652,10 +3904,6 @@ trackedFiles: id: 4fe7e3435585 last_write_checksum: sha1:ec91ab3086edea2be44b005c15a418fb8250a63f pristine_git_object: 9db2aa2b59de2596653d3255e925bfdaf11be3b5 - docs/models/setuppaymentremoveiteminterval.md: - id: 4f18ac80ce8a - last_write_checksum: sha1:27c66aacdb2bc4082c00ddd99f7391bd0d2560a9 - pristine_git_object: 17b64b61db5d924fabd30551fedb391e5b6654bc docs/models/setuppaymentresponse.md: id: a0fe36809906 last_write_checksum: sha1:0506cb5fd620fb73affa03da445160a245e94fd6 @@ -3712,6 +3960,14 @@ trackedFiles: id: f4060c3b4657 last_write_checksum: sha1:08c2c14481fcae1bcc1c550d6e2c95f14bb1efb0 pristine_git_object: 0ffbc190bacd9782b91444a81113d9249497800e + docs/models/trackdeduction1.md: + id: 68f814ae8dc3 + last_write_checksum: sha1:d33f1608ea5c543c3105c6e3853a22f2a8de2742 + pristine_git_object: aa83d39d5406722313993312f66160a8ef1abbdf + docs/models/trackdeduction2.md: + id: 3a3f94992106 + last_write_checksum: sha1:63711f6131d1179730aae5a5b5de67aab8cf206d + pristine_git_object: f2d2857c2dc8d3b966f1652c16577cbd8efd9fc8 docs/models/trackglobals.md: id: b4e733cd9cda last_write_checksum: sha1:70b3a0fc0755ff36e6d8e961d8ae4799535039d5 @@ -3754,12 +4010,64 @@ trackedFiles: pristine_git_object: b548eba246cbd9768b27415845b1fe14bd83a53e docs/models/trackresponsebody1.md: id: 5433fe0529e2 - last_write_checksum: sha1:8a66decfe9af544ccd61a81fa960771f0bad6dfe - pristine_git_object: c5f9f238b25885a18dbacb4c20ff06616454181f + last_write_checksum: sha1:95e4122804546446dafe60bc37dcf69be1ac0664 + pristine_git_object: edef9336ad8d911bb066235924a23cd14eba3eb7 docs/models/trackresponsebody2.md: id: 9687c70be905 - last_write_checksum: sha1:ba856df683f88d23764ceb6eb77d81056c96c8b3 - pristine_git_object: 2502dedfcaa631b8c5f9ce60fbdaa070e2d5ccf6 + last_write_checksum: sha1:7178a6a5a0817c474345ea7f30a58b508a2d0c91 + pristine_git_object: 5a9943a9c974e5dbbecab08273222a6354f40cc5 + docs/models/tracktokensdeduction1.md: + id: 1a758196b87e + last_write_checksum: sha1:41da6dca031c7b80e0d91ade330e69e47713123e + pristine_git_object: 9006f91d8634fcdbdd97890a026fef5fd34f1513 + docs/models/tracktokensdeduction2.md: + id: 3c22251b695d + last_write_checksum: sha1:fab620021055d38ca2c45212a0ce7fd24b40c433 + pristine_git_object: de42249a34c7c37d9daea2db35f827ab75c0218f + docs/models/tracktokensglobals.md: + id: 48cd999e6bc1 + last_write_checksum: sha1:4725aaaaf8881f072aeacf5bb2d40339656f8c5d + pristine_git_object: b00bb3e9b9457c999dfc53e947ecf054034be246 + docs/models/tracktokensintervalenum1.md: + id: febcb07548ec + last_write_checksum: sha1:d03fdf8faea5065c5e15fc975139189746afa0b1 + pristine_git_object: d2e5f92d6d91e2b9568699409cd0b7301f491c31 + docs/models/tracktokensintervalenum2.md: + id: 1c9509ada401 + last_write_checksum: sha1:fff7179e55409845f43be189bda907eaf43dac4d + pristine_git_object: 0aac44c60067338f668ef87658474aeeec6d9dd0 + docs/models/tracktokensintervalunion1.md: + id: 6d7e93b27d8d + last_write_checksum: sha1:32cb86ff870273b1daf0d22b812328e8b440afe6 + pristine_git_object: 696caa04f34202ff591e7cb22e11c5b9df16aa90 + docs/models/tracktokensintervalunion2.md: + id: 462839f6333c + last_write_checksum: sha1:6e03f0c54813f37b52472f67a8967578ac3a8467 + pristine_git_object: c0417689f745a33c0b81ba7cf27eab62c024a02a + docs/models/tracktokensparams.md: + id: 67d92f3d9fdd + last_write_checksum: sha1:0b13135c22ae3ef93b203a137ea9c0fbaa356226 + pristine_git_object: a4ab56a4647bc39b1eeb293b02f3b276d73aac9b + docs/models/tracktokensreset1.md: + id: 3ddd5c092a92 + last_write_checksum: sha1:09280ff6dc2b9c94c45677c27a755eb4e9e1871b + pristine_git_object: 99ae840e302b58fa8bcd3d46c83c2c56b7cd7908 + docs/models/tracktokensreset2.md: + id: 9cdfb9898ed4 + last_write_checksum: sha1:979900d600a195ce2a5976c809abef7e8d825925 + pristine_git_object: 2187c10934c2e7d219c6a15d68173977da81588c + docs/models/tracktokensresponse.md: + id: 991177e45c18 + last_write_checksum: sha1:c8b280f5abe41582a4d34c376104ee9dae097b54 + pristine_git_object: dcf11cfeecfb26c79b24dab2e524d0c8a32e8b82 + docs/models/tracktokensresponsebody1.md: + id: 658bbba3fc8d + last_write_checksum: sha1:281db3da8917e86ea537420cc1852fc01615d0cb + pristine_git_object: 321a96884b70c180e1174435b0756fb3c8efbf28 + docs/models/tracktokensresponsebody2.md: + id: a5774f13c9f2 + last_write_checksum: sha1:e4085c5ba5857943ea5260ea7501dc42d92b2a8e + pristine_git_object: 5a91ea2140bcd522eb344252571dde0ab9d182bc docs/models/trialsused.md: id: d3a87e402a87 last_write_checksum: sha1:e94b335fde33fa867d83a085af54e56e003b468d @@ -3818,8 +4126,8 @@ trackedFiles: pristine_git_object: 15447cc6e29a6523fe3367bba9bed19fcb10bb5b docs/models/updatecustomerfeature.md: id: fea7c9019bce - last_write_checksum: sha1:6f951a1a22bc74d3c1460f281af27fe01b7940fc - pristine_git_object: 9457044e8681ac41a26970ccdf22520dbd9b61e3 + last_write_checksum: sha1:aaf4af06d9fbaf4d242d77c9bf13ca7d81e7db2e + pristine_git_object: 790ff48f1cc3460db16a9aac63d0b41943b2c384 docs/models/updatecustomerflags.md: id: 169ad2004d84 last_write_checksum: sha1:54ffdcec7d956084e64e96f4cecff067c35bcffb @@ -3828,10 +4136,10 @@ trackedFiles: id: f8ba9d61dfdf last_write_checksum: sha1:188bd01790d033d4f2d2613242e1261d79e064db pristine_git_object: 5010263b034bbc3eaddad96700c1088373e6b6d5 - docs/models/updatecustomerintervalrequest.md: - id: 148680817ee5 - last_write_checksum: sha1:db7d05cc49e59dfdf529bb035474ea200383e25d - pristine_git_object: 3574243b504d51ea32f3ffc1535d7a222f5fa8d2 + docs/models/updatecustomerintervalrequestbody.md: + id: 83811a564d10 + last_write_checksum: sha1:bef06b998d624b8a3c28b4236cc07135bd68bcb3 + pristine_git_object: e662c3148ca31b5bb8c824ba93e4308cbafee8fd docs/models/updatecustomerintervalresponse1.md: id: 2bdfb280cf63 last_write_checksum: sha1:84738029b04b030ffb2d6249afb831045fe741c5 @@ -3840,6 +4148,10 @@ trackedFiles: id: ad8ca467f52b last_write_checksum: sha1:c78f65f67be9997b154eb580cfe417273e0f42c7 pristine_git_object: 9c83c09aa830ab2e3a25778c234916ef6d85158e + docs/models/updatecustomermodelmarkups.md: + id: 486f0afc7f86 + last_write_checksum: sha1:d7082493d4784eb3cbfbf95ec268622236283a1f + pristine_git_object: 89f74b8181a3b1783247f09dcedc86ac508e9677 docs/models/updatecustomeroverageallowedrequest.md: id: a922e0ffcfca last_write_checksum: sha1:f0af80e3c6d65be006ca4ed04a902ed23ffaac23 @@ -3856,14 +4168,18 @@ trackedFiles: id: 1be7d400e873 last_write_checksum: sha1:3660ced98b9c064fc04311cc97c6c9bb08c0c112 pristine_git_object: 142dca345d9ae6392e2bd53d30a4545f20f71967 + docs/models/updatecustomerprovidermarkups.md: + id: 4fc031717ff2 + last_write_checksum: sha1:acbd4f08ba5ce42cc7f9f671c339c3b9500e6de1 + pristine_git_object: 1847b97956a0383f8af2285bddb226663ca02422 docs/models/updatecustomerpurchase.md: id: 392cb675471e last_write_checksum: sha1:8398970612f1a0b5212d5ba7184ca5645edd7beb pristine_git_object: 60c28a4851cbd7bda36975825621bb139d979174 docs/models/updatecustomerpurchaselimitrequest.md: id: e0b5bae8abfe - last_write_checksum: sha1:9b519a82f89e6a2d841840521779ff3d8a0fdb50 - pristine_git_object: 3a815fe2cfe8300a5dd33afddad832c95b8e626e + last_write_checksum: sha1:be1a64b473cfec4e464461087e2c4a72de0ca3b9 + pristine_git_object: 17bdbd67911ee9d4a4405435e9d837d1e71ba5cc docs/models/updatecustomerpurchaselimitresponse1.md: id: accb82caac88 last_write_checksum: sha1:f7838f0009df447ea4c73978e4d1a36e7df8d91d @@ -3922,8 +4238,8 @@ trackedFiles: pristine_git_object: f5a2ba185730e2d0f7267ab095e5ef7c292e5590 docs/models/updatecustomertype.md: id: ade4d76bc6b0 - last_write_checksum: sha1:fe24ab4f286b788135bd5a2eef54ba192931c40e - pristine_git_object: 1bba5fdc5ccf2638a23acd427b9ed701db73898f + last_write_checksum: sha1:8e2c20572c657760f925246dd38f1616e781bc82 + pristine_git_object: 83f598b35e560be2d600c637256e033d9170e1e4 docs/models/updatecustomerusagealertrequestbody.md: id: 048dc0a2be61 last_write_checksum: sha1:dbd17ef5c28802bd32a290d6a501dad213e4f4df @@ -3958,8 +4274,8 @@ trackedFiles: pristine_git_object: df5e9c11ec6ca45e6913af5ef3b1636bacce3c92 docs/models/updateentityfeature.md: id: 30b1d308d00d - last_write_checksum: sha1:993ad4f4aa1ef7ad0ba334265ed0dcfbfde7845d - pristine_git_object: afd17a312113d2d4aa3ffb2896854e7e501c5109 + last_write_checksum: sha1:407a2ffc1b580bff56da7e0ea16054e8908e4b1a + pristine_git_object: b843bb8c3992fa19a84097c8c4f6ee18a26827bd docs/models/updateentityflags.md: id: 3faf5a339b5c last_write_checksum: sha1:750aeba3fc94d9c009d17da3265c2d7e0eafeecf @@ -3972,6 +4288,10 @@ trackedFiles: id: 830fe676f08a last_write_checksum: sha1:7010ea4fad33c8ab492e05eeebf469823c0446d1 pristine_git_object: 56c265304b389df9bef37e048de0fe1104139ba1 + docs/models/updateentitymodelmarkups.md: + id: 4b0b384e3436 + last_write_checksum: sha1:c2e907eb7665fdd7d0bfc6c3976c0d67187e8964 + pristine_git_object: 827d1988068a78374e762dd520ae5215c2daeaf9 docs/models/updateentityoverageallowedrequest.md: id: 36be63c857ac last_write_checksum: sha1:2ebedd0f42cf70cb07999f84804cc648b9c2c47b @@ -3988,6 +4308,10 @@ trackedFiles: id: 302da9590675 last_write_checksum: sha1:5193e0e67de1c261cb733f9dd58aac9f22bd0d92 pristine_git_object: d839828b3a478b89212b9f1f3db21242f07a6004 + docs/models/updateentityprovidermarkups.md: + id: 3cbd5925d36c + last_write_checksum: sha1:9fac090945c912e9ed0d623cdf7c93ab378716fc + pristine_git_object: 1038578e1cf78a1a5e1d37fdeeaee44f904cd16f docs/models/updateentitypurchase.md: id: e6e0bb58543d last_write_checksum: sha1:a39e898f9f8f333cd1d508661d37562f87b919a7 @@ -4030,8 +4354,8 @@ trackedFiles: pristine_git_object: 499325e410a984254fb0f81181dae81abcb3ad16 docs/models/updateentitytype.md: id: c63e96d62ca8 - last_write_checksum: sha1:a683e322f2f743930a39dcd27d964917c82831a0 - pristine_git_object: 32f4009a0753446d5f977c1633d303dccc83a7df + last_write_checksum: sha1:d9304b09a5fec762925fd6e59a6abf317c68cd2b + pristine_git_object: d7c691f6963a23ba6cc89c131308502c0afcf5e9 docs/models/updateentityusagealertrequestbody.md: id: 0b89a7c9e9e7 last_write_checksum: sha1:e3e5f0c7abe4001db0e41c768b47966e32704838 @@ -4040,18 +4364,18 @@ trackedFiles: id: 13b4ce2e62c3 last_write_checksum: sha1:56161024d16a2d19bf4614733713e74c4584720e pristine_git_object: f16c67ee983fbed98c4de1b6c6d94fb025323eac - docs/models/updatefeaturecreditschemarequest.md: - id: 44ff205415c8 - last_write_checksum: sha1:83a7d17ecd1bf259e95f29a1b56e901f519df814 - pristine_git_object: c5fea3ab93872a75ae5579a46d69e0e850862bc4 + docs/models/updatefeaturecreditschemarequestbody.md: + id: a6957a503e19 + last_write_checksum: sha1:41dfc3ad250882dc5a280936da594c8ffa6c30af + pristine_git_object: 282b355dfd514668b7b9e6ab1135ca6c2c1f185b docs/models/updatefeaturecreditschemaresponse.md: id: 9f5eca075d94 last_write_checksum: sha1:fb24d828dbf5bc9f71990559f490419876251d72 pristine_git_object: 2e3973cb673b154a1291d82929cc13d7256c6151 - docs/models/updatefeaturedisplayrequest.md: - id: 33f127b198e9 - last_write_checksum: sha1:887bc4356cf88bc82d96e747c817ea1e9ce234fe - pristine_git_object: dcdb0c3beac7cd3050819d1cbae5d9ebc72ed931 + docs/models/updatefeaturedisplayrequestbody.md: + id: 5a83c68edb5b + last_write_checksum: sha1:b5aec197ba501787f9654ddec291cfb0a8324a87 + pristine_git_object: f73af2e3db5f73a26835bf50e3d4d5e529bdc8f8 docs/models/updatefeaturedisplayresponse.md: id: 9f8b6393a11e last_write_checksum: sha1:85bb64a99cd963b7dfb35a7d2540958662d5d195 @@ -4060,34 +4384,50 @@ trackedFiles: id: 82c9331904fe last_write_checksum: sha1:bde13dfe93e5ab90fd3da6ada4a8421f038aaaa2 pristine_git_object: d2df9171ab109b7974ed663e4ddabc11fbf11cab + docs/models/updatefeaturemodelmarkupsrequest.md: + id: a1ba070f4cda + last_write_checksum: sha1:40e6d8e10270c14b7426088ca07f2547c9b47359 + pristine_git_object: ac5a7434cf3f1237900ec47ff26c11cf469fe078 + docs/models/updatefeaturemodelmarkupsresponse.md: + id: cf2c7a82b796 + last_write_checksum: sha1:c700ba8db41b072079c31b2422a35068c5af1ab4 + pristine_git_object: 78ca8215a4b2da7dac4799a352b48cb9970a0d06 docs/models/updatefeatureparams.md: id: bce941590b68 - last_write_checksum: sha1:91d72efdabbfec37332a861ae73d05fd3a019b13 - pristine_git_object: 58c6373f6640fe18ff1fdabfec636c02de9da538 + last_write_checksum: sha1:13699f1e5a14b285b46013ac5f0ef9cb99ca054b + pristine_git_object: 7c4b49f5e0cbba60440469466d773692f4b7f09a + docs/models/updatefeatureprovidermarkupsrequest.md: + id: 9a5f568848a7 + last_write_checksum: sha1:82aa1a8c349561ddc9ad232e41181bd40d9e8f55 + pristine_git_object: 422ea44a57620b6279ce67a7b260685006535a08 + docs/models/updatefeatureprovidermarkupsresponse.md: + id: 1d84a100f268 + last_write_checksum: sha1:e6d7643933ba0ae54162809a037591fa7fa64483 + pristine_git_object: 7c1f4b2adbde761aabd5ca7990b195840ab682ef docs/models/updatefeatureresponse.md: id: 886eb6d8e173 - last_write_checksum: sha1:b4a233e3cba21e095b2c027487d38a8665cf6c1d - pristine_git_object: 846deae07339f3b6ca8b069c0220ef684dc58505 - docs/models/updatefeaturetyperequest.md: - id: 9dfd1c01e1fe - last_write_checksum: sha1:c29c39e961d308b69828e02d7a8db5dc5f620e6a - pristine_git_object: ccc1bfb836abfeb960f654facb15ff71012e8783 + last_write_checksum: sha1:711bfcc281e21a5dbfc60170a8bbf37fef28baec + pristine_git_object: c15adc27d00551f0607a247b80208c15299e786d + docs/models/updatefeaturetyperequestbody.md: + id: fb343e9a5367 + last_write_checksum: sha1:8fc184006ed0453ac90b677c77b12705b906c0c8 + pristine_git_object: fc33e8d83d38cd3ce026029630fa23fbb71bdafd docs/models/updatefeaturetyperesponse.md: id: 0ba0a862dcca - last_write_checksum: sha1:fb11dd3937490037fac83c816866d13881012cbf - pristine_git_object: 36459374751ba3aad0777c2ccba570553a0d7864 + last_write_checksum: sha1:ba4000eac9f25f6713b827f89da999285b58cc65 + pristine_git_object: 18e672cee28b83060d88007bb59a207cdc9629e0 docs/models/updateplanattachaction.md: id: 606933a404e1 last_write_checksum: sha1:d81a7652fc07d342634ecd173e8b6f9aed9fa43d pristine_git_object: ad36f93503838af401489616dfce1916dccb08f4 docs/models/updateplanbaseprice.md: id: 61443f3c272a - last_write_checksum: sha1:b554cc0015982aca7e2ab60294743f8da9d90290 - pristine_git_object: f8cdc4ea1952336cc36aba8e9631925a3c4eb88e - docs/models/updateplanbillingmethodrequest.md: - id: 9ece57bad5bb - last_write_checksum: sha1:194172cd5d620f3a2f7f0ec3ae3e967702d32d9c - pristine_git_object: 9965276937c54b62becfae3b972bae91490cd003 + last_write_checksum: sha1:e17e737ce094afe0e63a78866843f5dae9fca29e + pristine_git_object: 7870a26c6d41a18b8e8ad0a9895a7492e107c413 + docs/models/updateplanbillingmethodrequestbody.md: + id: a84ac4de1018 + last_write_checksum: sha1:9add03ffa86e2d6f1a4894dd7f139cd5c6fbf5cf + pristine_git_object: f0153825ec3cacf6aa9aaf16f0aa0675a02387ed docs/models/updateplanbillingmethodresponse.md: id: 1f6a0cc6dd9d last_write_checksum: sha1:1daaa2ce4ac4ec4dee50322a96f784fb93c7bb55 @@ -4120,10 +4460,10 @@ trackedFiles: id: 7b54ca1834fa last_write_checksum: sha1:59dfc5df1f8712bed19b33c7953ed726c325219c pristine_git_object: 9bf11e71cbf7d9dcf7bc6fc2fda970bd9cbec6bf - docs/models/updateplanexpirydurationtyperequest.md: - id: c44c66c550dc - last_write_checksum: sha1:22164f6840d087c3a7a8a73fa79cd846324a9e71 - pristine_git_object: eb290fc03e6bea2612f70e4b9bade7e50f5411ad + docs/models/updateplanexpirydurationtyperequestbody.md: + id: c7dafb778822 + last_write_checksum: sha1:7be522ea25cc1f90996fc6c3482b2594e0b0a593 + pristine_git_object: 61e3360559b1e0707b4d79cb2aa50220731b42f4 docs/models/updateplanexpirydurationtyperesponse.md: id: 3801fddf678d last_write_checksum: sha1:2e2e826b274e905bb1e75738004335828c74ba01 @@ -4156,14 +4496,14 @@ trackedFiles: id: a000c47b8faa last_write_checksum: sha1:d4cf8a095188f7f59696cfc7b4624e4a5f5d4866 pristine_git_object: 21629fa6207463ffa51a8292ea306694bb3702f0 - docs/models/updateplanitempriceintervalrequest.md: - id: 4ba82cce682a - last_write_checksum: sha1:c8ef5380b001a3e91ea378b900409e440661729e - pristine_git_object: 29d2f0b6c460c22e783b7806d7fce00ed59c57a6 + docs/models/updateplanitempriceintervalrequestbody.md: + id: 6dfa582724e2 + last_write_checksum: sha1:6359b95e3db7bdfe771e1d359317c9e1fc177ecc + pristine_git_object: ec593233e6dce497242d5a8ecdf773ebe4c845f5 docs/models/updateplanitempriceresponse.md: id: ad114972ca0e - last_write_checksum: sha1:2276382a193c330dfba34b277b4ed02a71bcd3c0 - pristine_git_object: 031e00002ab60c3dff8d4a63d6f732ea0aeee1b3 + last_write_checksum: sha1:1ad29e32271583224bde56baf5c2062fe7cf518b + pristine_git_object: c5d17c9290b33b73fb92a4107acb9fc589c7526c docs/models/updateplanondecrease.md: id: 02ccbf603191 last_write_checksum: sha1:b0db07b2c7b2e3c2fe5c07c9f62e1afbb48f26b1 @@ -4182,20 +4522,20 @@ trackedFiles: pristine_git_object: 0c2d7187551a45b5d678265295d3521f5286c11c docs/models/updateplanparams.md: id: ca9b432d1066 - last_write_checksum: sha1:cd8dfbe42194b1a5d517ef3d00d2fde207d47555 - pristine_git_object: 588843faecf6e53cbdcff8fec13f4aa6cb643eee + last_write_checksum: sha1:13d40672fe693156d07604d7bc92111496a61efe + pristine_git_object: 1d12e9248d6732067b341a64230fbc3732f153a8 docs/models/updateplanplanitem.md: id: c4cfe67e1766 - last_write_checksum: sha1:b7569bd3091d19bb02b4c231ee1d09d7a704fe0c - pristine_git_object: 88b816217c9a12ef6ed1a1db79821407d63da627 + last_write_checksum: sha1:db6fb1034d12dfa47f8d5a9b052f95a7b538b75b + pristine_git_object: d399b13f1bf6ccbc21d6272707124e220c3f5561 docs/models/updateplanpricedisplay.md: id: a0739df45768 last_write_checksum: sha1:596e9404a69cc2f412932fb602dab592e1b9056b pristine_git_object: acb2e90916dba40e2bf46027f5a9288945633565 - docs/models/updateplanpriceintervalrequest.md: - id: 616fa0a68bae - last_write_checksum: sha1:1a1639a4c538495479296e58794345164c2ba0f3 - pristine_git_object: 4461a24c16d64e87b6c3c2ace5c2477f426b3d84 + docs/models/updateplanpriceintervalrequestbody.md: + id: d6a97a007e7d + last_write_checksum: sha1:041bca6a3d8aa6b04865bee976af69221977e250 + pristine_git_object: 491eb5139211b9756ad15328bbb2e6782a7fcbec docs/models/updateplanpriceintervalresponse.md: id: fd5b2b703739 last_write_checksum: sha1:59cc2039b96d0c12ca4013f26e210a4091349383 @@ -4204,10 +4544,10 @@ trackedFiles: id: d6754f53396d last_write_checksum: sha1:85e7f34795a8d10bbfdab502835e26ff549e3abe pristine_git_object: 6abd0556d5f46c9bd9191b2e8175776f298acfae - docs/models/updateplanpricerequest.md: - id: 768f8e75b84e - last_write_checksum: sha1:62d78bf601e6d1ec1c7898b7e90ad2969f435808 - pristine_git_object: 59b8270a08e87ef309186b8720617211a05d7271 + docs/models/updateplanpricerequestbody.md: + id: be883c7b4919 + last_write_checksum: sha1:1db1d199f1ac9562941a7d0bd060779a9365f1f5 + pristine_git_object: a9a7055e70a206b07ada397fa7b4c67328c15653 docs/models/updateplanpriceresponse.md: id: 61ed2a03a5de last_write_checksum: sha1:8e99830778d69be073b959cf40ee78be83f7178e @@ -4216,18 +4556,18 @@ trackedFiles: id: 6c71cd3f66e2 last_write_checksum: sha1:157a4ca01eae2f8bc37522b009c8021e456a7b68 pristine_git_object: 2d494ae9f039b1cc5e0ce0744f92a4f8207c8eb8 - docs/models/updateplanresetintervalrequest.md: - id: 7c76042b1bd9 - last_write_checksum: sha1:baecc78ee81e32ab991330160f1ac34719be171c - pristine_git_object: e6e4563ea3e98ed341548c2bba5fcd30148b1e0a + docs/models/updateplanresetintervalrequestbody.md: + id: 24056f314fb6 + last_write_checksum: sha1:11479ced0b5c7dc7aedade1252558fe65127ae5a + pristine_git_object: 8fdda554783fa4f723d979c5f9dec398263c912b docs/models/updateplanresetintervalresponse.md: id: ce0550104677 last_write_checksum: sha1:859cf2ee39c30e833c8303c7a21deef66a0c9544 pristine_git_object: 1656ae6671cc4548ebf28a35ebe24b636e96466b - docs/models/updateplanresetrequest.md: - id: e5a7572f3d0c - last_write_checksum: sha1:7696cc75c02ddc1747d11d277ae1c39c98f33eed - pristine_git_object: 81466e5ed90e7493ebe0c67410f8ce3d8d5b955d + docs/models/updateplanresetrequestbody.md: + id: c3b7b815f44e + last_write_checksum: sha1:d13056296ec07e5229cffb067a76b86757f0f3c0 + pristine_git_object: 11a4d56ec0bbcb9b2f2ec53ae8280e39ee21a8e7 docs/models/updateplanresetresponse.md: id: 7e9115b0c55a last_write_checksum: sha1:b3d948c2588f6cbcd0005387faf1ec85a6a906c2 @@ -4236,10 +4576,10 @@ trackedFiles: id: 1e9b63fce660 last_write_checksum: sha1:c8598b6f5fe339f3a869367989ad65226fe0cde5 pristine_git_object: 5242964c1ffda11766a01a9519eb006b7fe02029 - docs/models/updateplanrolloverrequest.md: - id: 9b30d0aaa790 - last_write_checksum: sha1:d1f31d01c74db299c9ddae8c32acf06557264694 - pristine_git_object: a20e4a52840dd88d4fb0a9afc34ee98f66baea42 + docs/models/updateplanrolloverrequestbody.md: + id: 4f98eae6fc15 + last_write_checksum: sha1:e9fee61039baf3f231a67a9563178f7a74527157 + pristine_git_object: 7d0a2639296b03ed46711bf50dd6726aa5e721ef docs/models/updateplanrolloverresponse.md: id: 0a52d5c7913f last_write_checksum: sha1:bfac01fd389e545c41e135b3b4c15229ccd4c94c @@ -4248,26 +4588,34 @@ trackedFiles: id: 9fcfb9af1504 last_write_checksum: sha1:3d9a8b948f1dab91b439b25fe04f05a6cd8d3a40 pristine_git_object: 3875a75e71fe148db0cc5710ad55a4e86070312f - docs/models/updateplantier.md: - id: a0a2771747e8 - last_write_checksum: sha1:500cef705b585c0b909d21b60383ca6d0646f5b3 - pristine_git_object: bda0ee7ce74ebd4e280de314c55693b14198b20b - docs/models/updateplantierbehaviorrequest.md: - id: 73b92d1c113f - last_write_checksum: sha1:b1a89235e395df20de6baaba1caadb81d5824f6d - pristine_git_object: 6782a3ee1dc8a1d4d242b46eaa9c7b488e20a8b3 + docs/models/updateplantierbehaviorrequestbody.md: + id: c733dfc7e81a + last_write_checksum: sha1:82ebed33b84714c9e36b7a783aad4ce30d9bf197 + pristine_git_object: d720640adccf21bc8cc894c0ceff092524edb808 docs/models/updateplantierbehaviorresponse.md: id: 4cf0cabdcb8d last_write_checksum: sha1:fcbde197f9ffb6189dc6164ba517dd80c4380521 pristine_git_object: 1d073f866f2e50853948d8cfed3a796802f3d30a - docs/models/updateplanto.md: - id: e0ac738348d2 - last_write_checksum: sha1:44502fbd923cf1209624e883c4cb06c757171410 - pristine_git_object: de7349c14ecaa21bc7ec57d5c2f6667537ff0e46 + docs/models/updateplantierrequestbody.md: + id: f51be912175a + last_write_checksum: sha1:e87d5228e3dca9c52b0bd366d2b1d8ab803d33d7 + pristine_git_object: e7f509a88270ef4f15925eb735d67bf9b31cf8fa + docs/models/updateplantierresponse.md: + id: 51ec5eb77420 + last_write_checksum: sha1:3490a2805622d9fe75ad91eb9c56416221c9c6bc + pristine_git_object: b9d37c74fcf8775f7e45f25749720abb9a10c733 + docs/models/updateplantorequestbody.md: + id: 7cedefd67154 + last_write_checksum: sha1:6fd68c724452cb6799f1044d4aa45725e7b93675 + pristine_git_object: d5c9f2e2f1a771883f6a54054d86e84596f6bf1b + docs/models/updateplantoresponse.md: + id: 4eea4a8f9244 + last_write_checksum: sha1:a79660ede0f38de9f1f8442cedb38f73be8a6846 + pristine_git_object: 56445be84abbca351c48cf0745d0c608d44e459d docs/models/updateplantype.md: id: edd98356600c - last_write_checksum: sha1:b0a891101d766bd45726dd49d9e48dd4b8948055 - pristine_git_object: e058f74c3f1ca285380a040c69c6a208a90d503f + last_write_checksum: sha1:47da79364415e950c334a58042ba8f435976f347 + pristine_git_object: 45a916cbf34d1217b98c0e62fb7cf8ca1a37943b docs/models/updatesubscriptionparams.md: id: df1b06618f8b last_write_checksum: sha1:634719e86ea2e447c7941ab3f97e73085fec5a9c @@ -4290,8 +4638,8 @@ trackedFiles: pristine_git_object: 9632d9df66ca144a883a6a61092994f90b6b636c docs/sdks/autumn/README.md: id: d27c9292a1a3 - last_write_checksum: sha1:eebf60afdb680b0a6f5cd3a56fb67f7c0ee21796 - pristine_git_object: d5d6c787a26c2277c28af9873450cfb33bc50130 + last_write_checksum: sha1:d2961570fa3f260a44e64d667f6dab2d844a1acb + pristine_git_object: db5f6468bb0f638fc0d9b7d603efacd233698123 docs/sdks/balances/README.md: id: 6ca85866f00d last_write_checksum: sha1:486057543b5d9ba28039e8c521c6da05b318a378 @@ -4314,12 +4662,12 @@ trackedFiles: pristine_git_object: d5ead9109b17b6e015de5af749a240dadd440068 docs/sdks/features/README.md: id: e885cfb7247b - last_write_checksum: sha1:7f8cc6ca8855001c309a48738fb12782b09d3cf1 - pristine_git_object: e780c68e76247300ddcad095c859c1312e268eed + last_write_checksum: sha1:0eab41adcfb67679ce0839a4a4e997be33c47497 + pristine_git_object: 337f58429b8e20186d1780093f18e4bf96c6133e docs/sdks/plans/README.md: id: 2d8c741fff57 - last_write_checksum: sha1:a956c9b35832ad1b4b988220bbf133aa87c73301 - pristine_git_object: 25a47ac0fdb8bc31110b3d96d2656bf744969f37 + last_write_checksum: sha1:69405405f9e9e7d82d47c35250c64f8277e50087 + pristine_git_object: b0b2e5bc75c618688cfc86fd2c4285b38c98d4b0 docs/sdks/platform/README.md: id: b66219e9cd4d last_write_checksum: sha1:e18d4e05e801ff5b52414fc51e98e10f33cfbb04 @@ -4414,68 +4762,68 @@ trackedFiles: pristine_git_object: b346cbffe1811393d45f0e89673cd2b27e64b27f src/autumn_sdk/features.py: id: 79d780190f74 - last_write_checksum: sha1:24f5dcce8bc1f0afc6f2dc0af6e2bc71ca1c1d8d - pristine_git_object: 788d299f5f08153678bc710821d8b9e8d2406c15 + last_write_checksum: sha1:bde65f822ffbea7fac980d2d97be43a59406455c + pristine_git_object: bccd9653f7d1afae9b63462666eef440aa5c0753 src/autumn_sdk/httpclient.py: id: d09fa60cf82e last_write_checksum: sha1:5e55338d6ee9f01ab648cad4380201a8a3da7dd7 pristine_git_object: 89560b566073785535643e694c112bedbd3db13d src/autumn_sdk/models/__init__.py: id: bcf3802243ff - last_write_checksum: sha1:0300fd72a06e0e2bb6fdf06654e88e670fd575bb - pristine_git_object: 923db4da1df1e544bfa79c2fc5b55c90a1a3fb5e + last_write_checksum: sha1:2e9f28edd9402f294b221424315dc6bcb2cc13c2 + pristine_git_object: 59af463bdeb8534dc52829d9f5fafdb8abba4f22 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:72a13011c760cc0a83634bcad244dc7982bde3e6 - pristine_git_object: 5d591a0d1f939a606ba7639031b4ac7ed6ef1787 + last_write_checksum: sha1:a0fe2bacf1d1061ae82e628897a0f2b094fd1605 + pristine_git_object: 8f0e48bd4fecd9aa775a8f875be71a71165adedf src/autumn_sdk/models/balance.py: id: a6354d7c4b97 - last_write_checksum: sha1:54a4422123d262666370d2e1238d990ef1dba324 - pristine_git_object: 10a5b2ae10d0daf7adc5e9f90de4d25665732a62 + last_write_checksum: sha1:b5ce91bd9a42de2332529dd64e83aa1154fd3d0e + pristine_git_object: 0b7d37dc5f9307e286a7536bbe78fd2a665cfd71 src/autumn_sdk/models/batchtrackop.py: id: 5ebcf495dcb3 last_write_checksum: sha1:2b190852c3ee028f1b433c82f57c817ae41ee8c9 pristine_git_object: 4fc065eaa77c761dbe2461c74650a44421522d9b src/autumn_sdk/models/billingupdateop.py: id: a2f17c75cfd3 - last_write_checksum: sha1:4b04b09a26d52fb5c963b0d385484ecde0f928be - pristine_git_object: ac6c4e6ec0e3ab0c7990440e6d6f04fbef840bdb + last_write_checksum: sha1:d4897e087eeec6e5782f57d7a37a625f4c0992ad + pristine_git_object: 1b3cfbd4e744503923329ea3f3b4932c05be4308 src/autumn_sdk/models/checkop.py: id: 31c2f84723c6 - last_write_checksum: sha1:8711ca8b987fad934aea167a38b5d614836bcdb9 - pristine_git_object: 6c98274a990207b1690198e94541c51531e01c4e + last_write_checksum: sha1:f4ccb1028558f8833d9245e7c0f7ddc495e1d203 + pristine_git_object: 5ca4b0b88df8c8c2e10bc41884e8322a5c1cc1fa 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:721f83159168f5d68668a47881867cf35482973b - pristine_git_object: 1ad23c8de773c688f1c6a152131ea67f40ff7d3a + last_write_checksum: sha1:2ddc9cf500c72b3b02d1b9990bf37e7a8918d67a + pristine_git_object: 87b3d95cc0f9ed599e5d17463289291c62bdd99a src/autumn_sdk/models/createfeatureop.py: id: 68487033fbe5 - last_write_checksum: sha1:c68f6e79a3dc3d3436d607fae75e4c4a958b7141 - pristine_git_object: 3a3fa7e5caf97561ce5ea6f494941cb1ce64b67c + last_write_checksum: sha1:7c8a14194d5d156e05863ff04296b4f85c9ef4a0 + pristine_git_object: 496a076933b3e87f7a049a22543d43e13b34ae56 src/autumn_sdk/models/createplanop.py: id: 077e6c7db2ad - last_write_checksum: sha1:abd3f7dda2a80ddeeee92ec78efb394384f8db93 - pristine_git_object: 1b6499a88233db6b079ec7add34d9726331f329d + last_write_checksum: sha1:795a97e1ee7567641136141697d82a9e90c7f497 + pristine_git_object: 7496b2429701769f5c5bc482d9e8fa6dc8ced7c1 src/autumn_sdk/models/createreferralcodeop.py: id: 2f5f7b136c39 last_write_checksum: sha1:9ef6a7e87ffd4cb0fb91f3592abb3b1ef51cb415 pristine_git_object: fea4d1bb3875848e66502705e4cb818233ec3f5a src/autumn_sdk/models/createscheduleop.py: id: afb0cf1cf7f2 - last_write_checksum: sha1:4046f367525814636a58b5ebe52b3b7356047107 - pristine_git_object: 201b77cbeb42ce0b466bc4134b107bc3b9306aed + last_write_checksum: sha1:678b4aeb569191113f441bef1c746458d96361de + pristine_git_object: 27a8f37ca092fadc85ae5f83848b9cdaf8abaeb0 src/autumn_sdk/models/customer.py: id: 8ed0174f7272 - last_write_checksum: sha1:e21377dcf67b7e242f80719b1296abf831efb3f8 - pristine_git_object: 47c3d131de0aa846cf1859d53e207db2debff0d3 + last_write_checksum: sha1:f1bdbff3880fbb5b9121a8654d46d6cba35d2c6e + pristine_git_object: e6448f8eba93a199432b3dfb8e9eebc99d3ccebb src/autumn_sdk/models/customerdata.py: id: 9d88118f2123 last_write_checksum: sha1:3342d09cf62cc51d935fecf1edfac126bcc203de @@ -4510,24 +4858,24 @@ trackedFiles: pristine_git_object: f10712da219176f9298c3426dd7b1372ae8777dd src/autumn_sdk/models/getcustomerop.py: id: 266e08dde55d - last_write_checksum: sha1:e8a423a0a9103f8ac3c95980039b6f3c8f036040 - pristine_git_object: a4ec8bc47a4f820b93465cea1e8bd25f6d714c68 + last_write_checksum: sha1:aa05f68b9b9c1b875d5854d7cea223f854eb0b4b + pristine_git_object: 121444d9b620cd4c167aa9ce061295776d07ea91 src/autumn_sdk/models/getentityop.py: id: 6a624594b41f - last_write_checksum: sha1:30eb1ff20357f6dafd502ea182576d0a7929f3e3 - pristine_git_object: 1dee2063987a25d34eccb143f91d95d603305f10 + last_write_checksum: sha1:588e448dae303a856aacd1cd44baf5c855465544 + pristine_git_object: 5d29deba9a8a9547068e1dff40bb6191b2933711 src/autumn_sdk/models/getfeatureop.py: id: 72b158789497 - last_write_checksum: sha1:dccbc8de6a2d4cc07a4045585f12d8649b9f4c0a - pristine_git_object: a3b6d982c56568f07fc5b226e4d836b62dc7d331 + last_write_checksum: sha1:79852a1174b310e10a220afb9dd1843a21c30f9a + pristine_git_object: e5644620facf26204c3a512525886fed0054c447 src/autumn_sdk/models/getorcreatecustomerop.py: id: acfac0d7be14 last_write_checksum: sha1:524b618ee1defe01311abb69aebedfa737015214 pristine_git_object: ecf53428c556a2515c44ee3bb7e31a2aab91f1f4 src/autumn_sdk/models/getplanop.py: id: 590fb77ac88d - last_write_checksum: sha1:4b545b24018986ba9d93107df7840f39fb1f861c - pristine_git_object: e619e84014109d0ac73df596b4926a8f6dc92ee3 + last_write_checksum: sha1:00dea05a8fc3e03e14524f99740fe8bbca298f70 + pristine_git_object: 5db913303921e234957dc2beabdff45a9ed9254f src/autumn_sdk/models/getrevenuecatkeysop.py: id: 015155862a71 last_write_checksum: sha1:6b3f88a48b721093562ec59db3f6dae2540fa139 @@ -4546,48 +4894,48 @@ trackedFiles: pristine_git_object: 2a117515b5cac185ba0b7eb21d3b2b4d8260d194 src/autumn_sdk/models/listcustomersop.py: id: d7074740b8b0 - last_write_checksum: sha1:64e98dae9bc3719c50a81e306ce270ad59d2e216 - pristine_git_object: 732fd3a8749565a2886b2ece22654a19adca12b3 + last_write_checksum: sha1:60e6ead98a1e75d18b5dd5ddbe131560d5a1b9f4 + pristine_git_object: 684a59d0c5c1bff892abfc202b65caed95be1c08 src/autumn_sdk/models/listentitiesop.py: id: 918a05430967 - last_write_checksum: sha1:a79107d8810f0dab4073abf172bf45e981a2414e - pristine_git_object: dfbd606cf76f9ee1d54f276711d7bbe5f5958bd3 + last_write_checksum: sha1:e5572e05cad12ac0969281bd3bac196ab7679b33 + pristine_git_object: 59dbb9d6aa4a7df6b8a24b3b2eaab2275db5a271 src/autumn_sdk/models/listeventsop.py: id: 751b0200d91d last_write_checksum: sha1:a57b8d56c96c341e8572b4395c12209a62ff6b74 pristine_git_object: 86a98a1d2f3df5e7a53326e54a9ddb2883b711ed src/autumn_sdk/models/listfeaturesop.py: id: 95f88614bd8e - last_write_checksum: sha1:3c2e2e44a81954ab08a70e7fa8dfbea30ab0aeb8 - pristine_git_object: 8819d5dc8edea75b53ba1d465773b158eb95f310 + last_write_checksum: sha1:0529a71289061a5314b0b511a0cc2fe9d2139cf4 + pristine_git_object: b5077df0f24113f9e7c5fae25e5fefd923b7ec85 src/autumn_sdk/models/listplansop.py: id: fdf892c403f4 - last_write_checksum: sha1:eec171b45ca5d8b9e7d13e307c9125a0e6c92970 - pristine_git_object: cc085894937b7e149381b51729063f03d39e4eb5 + last_write_checksum: sha1:ac72c3bb673d385b548be6b88a9d58d7c0f556bf + pristine_git_object: e26e0984d93ea676b90d728e937f50897e8e0653 src/autumn_sdk/models/multiattachop.py: id: dfdf7952c870 - last_write_checksum: sha1:1f2b8d06e418f2f4c859d9fb45c11749abefb11e - pristine_git_object: ece0d519eef5f4260fe03a8f46442aeaeabbe603 + last_write_checksum: sha1:934c1cd9e2c92bf2a71bef8fcea12c4b8c90882b + pristine_git_object: ccfe5c9bde45b1b8b971e67a3457933a280f3d9a src/autumn_sdk/models/opencustomerportalop.py: id: 004cc9a6466f last_write_checksum: sha1:e32037dcfea1c4bd953f749f74775b3d7d1d83c3 pristine_git_object: 79ac016f241d82916ccc1aab7c7e3a7e3fb9aef1 src/autumn_sdk/models/plan.py: id: f85c4e07540d - last_write_checksum: sha1:69a6d5e972a3d1d16bda1771940d66bbf403726d - pristine_git_object: 99032117da3cc10b694f41fd0bc7a0c1f2bd7973 + last_write_checksum: sha1:ed28f9951b38f02652825c72101f3e8a2efdae5e + pristine_git_object: ec2a59fd8cf96803178ad00e35b8406862171b9d src/autumn_sdk/models/previewattachop.py: id: 2b361be4bfa8 - last_write_checksum: sha1:7e70b858a262848520eae497c1531e24dfa4b415 - pristine_git_object: b43973c40ef8c6ef1e0fa653e2bb43b9a8d42762 + last_write_checksum: sha1:8e3aa613f8edf3931089129c784a0e271e5f51d0 + pristine_git_object: 62192215fc3c89a2dddbadaa8c23c74182a5dde8 src/autumn_sdk/models/previewmultiattachop.py: id: 963ffcd646a4 - last_write_checksum: sha1:6616d0af51ae3cc3db52de12e4045a9a4815f58a - pristine_git_object: 3af175b0bfafff76b5dc3a68eab50d9c1a1df296 + last_write_checksum: sha1:5de1204fb7826c29ad44668ffaefbef61d704161 + pristine_git_object: 614ed006da5335c1e486acd0d1a1a3b397deeabd src/autumn_sdk/models/previewupdateop.py: id: 081d5f08508d - last_write_checksum: sha1:5571478e10944c67207a30e257bcb9772474fb02 - pristine_git_object: dd5c8717db573b74909443623775c24c8cf53b04 + last_write_checksum: sha1:f987f70776ba585eacd815daf4170f7624fc7631 + pristine_git_object: fc079a34405c17598bdc118c22455171d13db84f src/autumn_sdk/models/redeemreferralcodeop.py: id: 0abd7bfae718 last_write_checksum: sha1:b1a584450f1f79e796dd755a9305dc30a07ed601 @@ -4602,40 +4950,44 @@ trackedFiles: pristine_git_object: aa686dd6f85ae1e27450392fcfe02527adfe8e61 src/autumn_sdk/models/setuppaymentop.py: id: 603339ee67e3 - last_write_checksum: sha1:46900f03adbb063677a4190d461c0460c470618e - pristine_git_object: 417d43b99b409c1d4e003c593097ac05ec5da795 + last_write_checksum: sha1:73546df41bf21aecd5c2947f22d2a2010f7d3f81 + pristine_git_object: 592b57ded6079ddb85946460542910ba3c05f6a7 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 - pristine_git_object: b99b67ec38db2ea46d6a4478ffc3f3d77625be13 + last_write_checksum: sha1:95bdbfae656861b2f8ec985b28b2f7d590a1cab7 + pristine_git_object: 8f25bd89ba801275eb8be21ef92b6e2ecf00a6b4 + src/autumn_sdk/models/tracktokensop.py: + id: 9486210c10ae + last_write_checksum: sha1:0fe1bfe0d8483ebac31f4096d7657c90d93b3da5 + pristine_git_object: 553eb4618256446d16d67a4b83d4a884710b79e5 src/autumn_sdk/models/updatebalanceop.py: id: cd80d90d4cae last_write_checksum: sha1:729cd503f116f20564b7bccc79c60ac1011554f2 pristine_git_object: c0bbec7e538787d31d5eaebafa5c9033b6df9da3 src/autumn_sdk/models/updatecustomerop.py: id: 28b9d5b59bae - last_write_checksum: sha1:e3a4cbc83a84322df6b5261c28d24554eb853733 - pristine_git_object: 0ab9bc3334f761b89f498ebab11570a023209987 + last_write_checksum: sha1:b73e43fc405ef229b25bac74cc020542e74dd0a0 + pristine_git_object: 510c7ff8b75006ad363688d01438e59e2eb2484a src/autumn_sdk/models/updateentityop.py: id: a49305af1e2e - last_write_checksum: sha1:8814635533bb9ecd0e8b0f5adf9f38189cb32beb - pristine_git_object: 5a828ceff98e07fd7e7f692414cb2ea6e4c4c5ea + last_write_checksum: sha1:370c109b2dc773e7667c71b9e0dead2e0672b534 + pristine_git_object: 479bf243eb2a82d5b95b4b1cc47ef9d9413cfcfb src/autumn_sdk/models/updatefeatureop.py: id: 2fdfed4aa2f2 - last_write_checksum: sha1:31b590aab5fd341dbf9d3ae8ec97f01fc6fe5090 - pristine_git_object: 563058b5a7d4a5805ba9d5109c7383ecbecd3d1c + last_write_checksum: sha1:6ebea2d2db44fbee88b9a7178282fa27f98064a7 + pristine_git_object: daa78399b45fcaeda3fbe5843677a6900c520d4d src/autumn_sdk/models/updateplanop.py: id: 753ddf45ca40 - last_write_checksum: sha1:7806983a99cefbb6ed167afdf8f4401d7d7f802d - pristine_git_object: 9a4531386163eb8a82dd510b4db243541535d7dd + last_write_checksum: sha1:2944abfbf0d7814e85955790f4d61edbac1248b3 + pristine_git_object: 9283737f1d4595a58765e1bffebdef58b181a0bb src/autumn_sdk/plans.py: id: cf1ebabb687c - last_write_checksum: sha1:8cca1565af6b67ab6947b016d0ab8f7a431fa824 - pristine_git_object: 4c299d37ebbc9a9185bb496926a5a326334dcbe7 + last_write_checksum: sha1:85ea7bdf7b96f0260160dbe7b8fe85910b461e68 + pristine_git_object: 57f4485f2f140f3c88df04fa15b7d3548fc26519 src/autumn_sdk/platform.py: id: aee79240c441 last_write_checksum: sha1:2e80cdba550e5487a2ac16c063e24caf3f2b265f @@ -4654,8 +5006,8 @@ trackedFiles: pristine_git_object: c52c86dd77e753356560ebcf0ee10f8dc46de593 src/autumn_sdk/sdk.py: id: 9e733b372628 - last_write_checksum: sha1:da2019a4fd1bc539f99076623e758d53baec25b7 - pristine_git_object: 8feede30ce5471864788d9ce7e6779ab9be5157c + last_write_checksum: sha1:5abce9e6115596a36f9d79279fffc9fa504760a3 + pristine_git_object: ef844917d0e2d3a8225a85eacd45c33f4d72c722 src/autumn_sdk/sdkconfiguration.py: id: e65df2e44fc0 last_write_checksum: sha1:233b710dff940202f00e389e0c8fa6a33f6ae7b4 @@ -5363,4 +5715,16 @@ examples: 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"} + trackTokens: + speakeasy-default-track-tokens: + parameters: + header: + x-api-version: "2.3.0" + requestBody: + application/json: {"customer_id": "cus_123", "feature_id": "ai_credits", "model_id": "anthropic/claude-sonnet-4-20250514", "input_tokens": 1000, "output_tokens": 500} + responses: + "200": + application/json: {"customer_id": "cus_123", "value": 0.006, "balance": {"feature_id": "ai_credits", "granted": 10, "remaining": 9.994, "usage": 0.006, "unlimited": false, "overage_allowed": false, "max_purchase": null, "next_reset_at": 1773851121437, "breakdown": [{"id": "cus_ent_39qmLooixXLAqMywgXywjAz96rV", "plan_id": "pro_plan", "included_grant": 100, "prepaid_grant": 0, "remaining": 72, "usage": 28, "unlimited": false, "reset": {"interval": "month", "resets_at": 1773851121437}, "price": null, "expires_at": null}]}, "deductions": [{"balance_id": "cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2", "feature_id": "ai_credits", "plan_id": "pro", "reset": {"interval": "month", "resets_at": 1781288736881}, "value": 0.006}]} + "202": + application/json: {"customer_id": "cus_123", "value": 0.006, "balance": {"feature_id": "ai_credits", "granted": 10, "remaining": 9.994, "usage": 0.006, "unlimited": false, "overage_allowed": false, "max_purchase": null, "next_reset_at": 1773851121437, "breakdown": [{"id": "cus_ent_39qmLooixXLAqMywgXywjAz96rV", "plan_id": "pro_plan", "included_grant": 100, "prepaid_grant": 0, "remaining": 72, "usage": 28, "unlimited": false, "reset": {"interval": "month", "resets_at": 1773851121437}, "price": null, "expires_at": null}]}, "deductions": [{"balance_id": "cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2", "feature_id": "ai_credits", "plan_id": "pro", "reset": {"interval": "month", "resets_at": 1781288736881}, "value": 0.006}]} examplesVersion: 1.0.2 diff --git a/others/python-sdk/README.md b/others/python-sdk/README.md index 737b6c390..750ace580 100644 --- a/others/python-sdk/README.md +++ b/others/python-sdk/README.md @@ -200,6 +200,9 @@ Use this to gate access before a feature action. Enable sendEvent when you want * [track](docs/sdks/autumn/README.md#track) - Records usage for a customer feature and returns updated balances. Use this after an action happens to decrement usage, or send a negative value to credit balance back. +* [track_tokens](docs/sdks/autumn/README.md#track_tokens) - Records AI token usage for a customer and returns the updated AI credit balance. + +Use this after an LLM request when you have input and output token counts. Autumn converts token usage to a dollar amount using the configured model pricing and markup, then tracks that value against the customer's AI credit system. * [batch_track](docs/sdks/autumn/README.md#batch_track) - Enqueue up to 1000 usage events for asynchronous processing. Items are validated synchronously up front; validated items are then enqueued via SQS for background deduction by workers. The response returns 202 immediately and does not include balance information. On partial enqueue failure (some items fail to enqueue, others succeed), the endpoint still returns 202 and logs the failures server-side; clients should NOT retry, because retrying re-enqueues the already-succeeded items. A 503 is returned only when zero items were successfully enqueued (queue entirely unavailable) — that case is safe to retry. ### [Balances](docs/sdks/balances/README.md) diff --git a/others/python-sdk/src/autumn_sdk/features.py b/others/python-sdk/src/autumn_sdk/features.py index 788d299f5..bccd9653f 100644 --- a/others/python-sdk/src/autumn_sdk/features.py +++ b/others/python-sdk/src/autumn_sdk/features.py @@ -5,7 +5,7 @@ from autumn_sdk import errors, models, utils from autumn_sdk._hooks import HookContext from autumn_sdk.types import BaseModel, OptionalNullable, UNSET from autumn_sdk.utils.unmarshal_json_response import unmarshal_json_response -from typing import List, Mapping, Optional, Union, cast +from typing import Dict, List, Mapping, Optional, Union, cast class Features(BaseSDK): @@ -13,21 +13,34 @@ class Features(BaseSDK): self, *, name: str, - type_: models.CreateFeatureTypeRequest, + type_: models.CreateFeatureTypeRequestBody, feature_id: str, consumable: Optional[bool] = None, display: Optional[ Union[ - models.CreateFeatureDisplayRequest, - models.CreateFeatureDisplayRequestTypedDict, + models.CreateFeatureDisplayRequestBody, + models.CreateFeatureDisplayRequestBodyTypedDict, ] ] = None, credit_schema: Optional[ Union[ - List[models.CreateFeatureCreditSchemaRequest], - List[models.CreateFeatureCreditSchemaRequestTypedDict], + List[models.CreateFeatureCreditSchemaRequestBody], + List[models.CreateFeatureCreditSchemaRequestBodyTypedDict], ] ] = None, + model_markups: OptionalNullable[ + Union[ + Dict[str, models.CreateFeatureModelMarkupsRequest], + Dict[str, models.CreateFeatureModelMarkupsRequestTypedDict], + ] + ] = UNSET, + default_markup: Optional[float] = None, + provider_markups: OptionalNullable[ + Union[ + Dict[str, models.CreateFeatureProviderMarkupsRequest], + Dict[str, models.CreateFeatureProviderMarkupsRequestTypedDict], + ] + ] = UNSET, event_names: Optional[List[str]] = None, retries: OptionalNullable[utils.RetryConfig] = UNSET, server_url: Optional[str] = None, @@ -43,7 +56,10 @@ class Features(BaseSDK): :param feature_id: The ID of the feature to create. :param consumable: Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features. :param display: Singular and plural display names for the feature in your user interface. - :param credit_schema: A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. + :param credit_schema: A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. + :param model_markups: Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. + :param default_markup: Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. + :param provider_markups: Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. :param event_names: :param retries: Override the default retry configuration for this method :param server_url: Override the default server URL for this method @@ -65,10 +81,20 @@ class Features(BaseSDK): type=type_, consumable=consumable, display=utils.get_pydantic_model( - display, Optional[models.CreateFeatureDisplayRequest] + display, Optional[models.CreateFeatureDisplayRequestBody] ), credit_schema=utils.get_pydantic_model( - credit_schema, Optional[List[models.CreateFeatureCreditSchemaRequest]] + credit_schema, + Optional[List[models.CreateFeatureCreditSchemaRequestBody]], + ), + model_markups=utils.get_pydantic_model( + model_markups, + OptionalNullable[Dict[str, models.CreateFeatureModelMarkupsRequest]], + ), + default_markup=default_markup, + provider_markups=utils.get_pydantic_model( + provider_markups, + OptionalNullable[Dict[str, models.CreateFeatureProviderMarkupsRequest]], ), event_names=event_names, feature_id=feature_id, @@ -137,21 +163,34 @@ class Features(BaseSDK): self, *, name: str, - type_: models.CreateFeatureTypeRequest, + type_: models.CreateFeatureTypeRequestBody, feature_id: str, consumable: Optional[bool] = None, display: Optional[ Union[ - models.CreateFeatureDisplayRequest, - models.CreateFeatureDisplayRequestTypedDict, + models.CreateFeatureDisplayRequestBody, + models.CreateFeatureDisplayRequestBodyTypedDict, ] ] = None, credit_schema: Optional[ Union[ - List[models.CreateFeatureCreditSchemaRequest], - List[models.CreateFeatureCreditSchemaRequestTypedDict], + List[models.CreateFeatureCreditSchemaRequestBody], + List[models.CreateFeatureCreditSchemaRequestBodyTypedDict], ] ] = None, + model_markups: OptionalNullable[ + Union[ + Dict[str, models.CreateFeatureModelMarkupsRequest], + Dict[str, models.CreateFeatureModelMarkupsRequestTypedDict], + ] + ] = UNSET, + default_markup: Optional[float] = None, + provider_markups: OptionalNullable[ + Union[ + Dict[str, models.CreateFeatureProviderMarkupsRequest], + Dict[str, models.CreateFeatureProviderMarkupsRequestTypedDict], + ] + ] = UNSET, event_names: Optional[List[str]] = None, retries: OptionalNullable[utils.RetryConfig] = UNSET, server_url: Optional[str] = None, @@ -167,7 +206,10 @@ class Features(BaseSDK): :param feature_id: The ID of the feature to create. :param consumable: Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features. :param display: Singular and plural display names for the feature in your user interface. - :param credit_schema: A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. + :param credit_schema: A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. + :param model_markups: Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. + :param default_markup: Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. + :param provider_markups: Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. :param event_names: :param retries: Override the default retry configuration for this method :param server_url: Override the default server URL for this method @@ -189,10 +231,20 @@ class Features(BaseSDK): type=type_, consumable=consumable, display=utils.get_pydantic_model( - display, Optional[models.CreateFeatureDisplayRequest] + display, Optional[models.CreateFeatureDisplayRequestBody] ), credit_schema=utils.get_pydantic_model( - credit_schema, Optional[List[models.CreateFeatureCreditSchemaRequest]] + credit_schema, + Optional[List[models.CreateFeatureCreditSchemaRequestBody]], + ), + model_markups=utils.get_pydantic_model( + model_markups, + OptionalNullable[Dict[str, models.CreateFeatureModelMarkupsRequest]], + ), + default_markup=default_markup, + provider_markups=utils.get_pydantic_model( + provider_markups, + OptionalNullable[Dict[str, models.CreateFeatureProviderMarkupsRequest]], ), event_names=event_names, feature_id=feature_id, @@ -628,20 +680,33 @@ class Features(BaseSDK): *, feature_id: str, name: Optional[str] = None, - type_: Optional[models.UpdateFeatureTypeRequest] = None, + type_: Optional[models.UpdateFeatureTypeRequestBody] = None, consumable: Optional[bool] = None, display: Optional[ Union[ - models.UpdateFeatureDisplayRequest, - models.UpdateFeatureDisplayRequestTypedDict, + models.UpdateFeatureDisplayRequestBody, + models.UpdateFeatureDisplayRequestBodyTypedDict, ] ] = None, credit_schema: Optional[ Union[ - List[models.UpdateFeatureCreditSchemaRequest], - List[models.UpdateFeatureCreditSchemaRequestTypedDict], + List[models.UpdateFeatureCreditSchemaRequestBody], + List[models.UpdateFeatureCreditSchemaRequestBodyTypedDict], ] ] = None, + model_markups: OptionalNullable[ + Union[ + Dict[str, models.UpdateFeatureModelMarkupsRequest], + Dict[str, models.UpdateFeatureModelMarkupsRequestTypedDict], + ] + ] = UNSET, + default_markup: Optional[float] = None, + provider_markups: OptionalNullable[ + Union[ + Dict[str, models.UpdateFeatureProviderMarkupsRequest], + Dict[str, models.UpdateFeatureProviderMarkupsRequestTypedDict], + ] + ] = UNSET, event_names: Optional[List[str]] = None, archived: Optional[bool] = None, new_feature_id: Optional[str] = None, @@ -659,7 +724,10 @@ class Features(BaseSDK): :param type: The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. :param consumable: Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features. :param display: Singular and plural display names for the feature in your user interface. - :param credit_schema: A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. + :param credit_schema: A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. + :param model_markups: Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. + :param default_markup: Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. + :param provider_markups: Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. :param event_names: :param archived: Whether the feature is archived. Archived features are hidden from the dashboard. :param new_feature_id: The new ID of the feature. Feature ID can only be updated if it's not being used by any customers. @@ -683,10 +751,20 @@ class Features(BaseSDK): type=type_, consumable=consumable, display=utils.get_pydantic_model( - display, Optional[models.UpdateFeatureDisplayRequest] + display, Optional[models.UpdateFeatureDisplayRequestBody] ), credit_schema=utils.get_pydantic_model( - credit_schema, Optional[List[models.UpdateFeatureCreditSchemaRequest]] + credit_schema, + Optional[List[models.UpdateFeatureCreditSchemaRequestBody]], + ), + model_markups=utils.get_pydantic_model( + model_markups, + OptionalNullable[Dict[str, models.UpdateFeatureModelMarkupsRequest]], + ), + default_markup=default_markup, + provider_markups=utils.get_pydantic_model( + provider_markups, + OptionalNullable[Dict[str, models.UpdateFeatureProviderMarkupsRequest]], ), event_names=event_names, archived=archived, @@ -758,20 +836,33 @@ class Features(BaseSDK): *, feature_id: str, name: Optional[str] = None, - type_: Optional[models.UpdateFeatureTypeRequest] = None, + type_: Optional[models.UpdateFeatureTypeRequestBody] = None, consumable: Optional[bool] = None, display: Optional[ Union[ - models.UpdateFeatureDisplayRequest, - models.UpdateFeatureDisplayRequestTypedDict, + models.UpdateFeatureDisplayRequestBody, + models.UpdateFeatureDisplayRequestBodyTypedDict, ] ] = None, credit_schema: Optional[ Union[ - List[models.UpdateFeatureCreditSchemaRequest], - List[models.UpdateFeatureCreditSchemaRequestTypedDict], + List[models.UpdateFeatureCreditSchemaRequestBody], + List[models.UpdateFeatureCreditSchemaRequestBodyTypedDict], ] ] = None, + model_markups: OptionalNullable[ + Union[ + Dict[str, models.UpdateFeatureModelMarkupsRequest], + Dict[str, models.UpdateFeatureModelMarkupsRequestTypedDict], + ] + ] = UNSET, + default_markup: Optional[float] = None, + provider_markups: OptionalNullable[ + Union[ + Dict[str, models.UpdateFeatureProviderMarkupsRequest], + Dict[str, models.UpdateFeatureProviderMarkupsRequestTypedDict], + ] + ] = UNSET, event_names: Optional[List[str]] = None, archived: Optional[bool] = None, new_feature_id: Optional[str] = None, @@ -789,7 +880,10 @@ class Features(BaseSDK): :param type: The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. :param consumable: Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features. :param display: Singular and plural display names for the feature in your user interface. - :param credit_schema: A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. + :param credit_schema: A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. + :param model_markups: Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. + :param default_markup: Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. + :param provider_markups: Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. :param event_names: :param archived: Whether the feature is archived. Archived features are hidden from the dashboard. :param new_feature_id: The new ID of the feature. Feature ID can only be updated if it's not being used by any customers. @@ -813,10 +907,20 @@ class Features(BaseSDK): type=type_, consumable=consumable, display=utils.get_pydantic_model( - display, Optional[models.UpdateFeatureDisplayRequest] + display, Optional[models.UpdateFeatureDisplayRequestBody] ), credit_schema=utils.get_pydantic_model( - credit_schema, Optional[List[models.UpdateFeatureCreditSchemaRequest]] + credit_schema, + Optional[List[models.UpdateFeatureCreditSchemaRequestBody]], + ), + model_markups=utils.get_pydantic_model( + model_markups, + OptionalNullable[Dict[str, models.UpdateFeatureModelMarkupsRequest]], + ), + default_markup=default_markup, + provider_markups=utils.get_pydantic_model( + provider_markups, + OptionalNullable[Dict[str, models.UpdateFeatureProviderMarkupsRequest]], ), event_names=event_names, archived=archived, diff --git a/others/python-sdk/src/autumn_sdk/models/__init__.py b/others/python-sdk/src/autumn_sdk/models/__init__.py index 923db4da1..59af463bd 100644 --- a/others/python-sdk/src/autumn_sdk/models/__init__.py +++ b/others/python-sdk/src/autumn_sdk/models/__init__.py @@ -65,6 +65,10 @@ if TYPE_CHECKING: AttachFreeTrialParamsTypedDict, AttachGlobals, AttachGlobalsTypedDict, + AttachIntervalRemoveItemEnum1, + AttachIntervalRemoveItemEnum2, + AttachIntervalUnion, + AttachIntervalUnionTypedDict, AttachInvoice, AttachInvoiceMode, AttachInvoiceModeTypedDict, @@ -100,7 +104,6 @@ if TYPE_CHECKING: AttachProrationBehavior, AttachRedirectMode, AttachRemoveItemBillingMethod, - AttachRemoveItemInterval, AttachRequiredAction, AttachRequiredActionTypedDict, AttachResponse, @@ -118,13 +121,21 @@ if TYPE_CHECKING: BalanceIntervalEnum, BalanceIntervalUnion, BalanceIntervalUnionTypedDict, + BalanceModelMarkups, + BalanceModelMarkupsTypedDict, BalancePrice, BalancePriceTypedDict, + BalanceProviderMarkups, + BalanceProviderMarkupsTypedDict, BalanceReset, BalanceResetTypedDict, BalanceRollover, BalanceRolloverTypedDict, + BalanceTier, BalanceTierBehavior, + BalanceTierTypedDict, + BalanceTo, + BalanceToTypedDict, BalanceType, BalanceTypedDict, Breakdown, @@ -177,6 +188,10 @@ if TYPE_CHECKING: BillingUpdateFreeTrialParamsTypedDict, BillingUpdateGlobals, BillingUpdateGlobalsTypedDict, + BillingUpdateIntervalRemoveItemEnum1, + BillingUpdateIntervalRemoveItemEnum2, + BillingUpdateIntervalUnion, + BillingUpdateIntervalUnionTypedDict, BillingUpdateInvoice, BillingUpdateInvoiceMode, BillingUpdateInvoiceModeTypedDict, @@ -211,7 +226,6 @@ if TYPE_CHECKING: BillingUpdateRecalculateBalancesTypedDict, BillingUpdateRedirectMode, BillingUpdateRemoveItemBillingMethod, - BillingUpdateRemoveItemInterval, BillingUpdateRequiredAction, BillingUpdateRequiredActionTypedDict, BillingUpdateResponse, @@ -248,6 +262,10 @@ if TYPE_CHECKING: CheckItem2TypedDict, CheckLock, CheckLockTypedDict, + CheckModelMarkups1, + CheckModelMarkups1TypedDict, + CheckModelMarkups2, + CheckModelMarkups2TypedDict, CheckOnDecrease1, CheckOnDecrease2, CheckOnEnd1, @@ -260,6 +278,10 @@ if TYPE_CHECKING: CheckProduct1TypedDict, CheckProduct2, CheckProduct2TypedDict, + CheckProviderMarkups1, + CheckProviderMarkups1TypedDict, + CheckProviderMarkups2, + CheckProviderMarkups2TypedDict, CheckResponse, CheckResponseBody1, CheckResponseBody1TypedDict, @@ -345,6 +367,8 @@ if TYPE_CHECKING: CreateEntityGlobalsTypedDict, CreateEntityInvoice, CreateEntityInvoiceTypedDict, + CreateEntityModelMarkups, + CreateEntityModelMarkupsTypedDict, CreateEntityOverageAllowedRequest, CreateEntityOverageAllowedRequestTypedDict, CreateEntityOverageAllowedResponse, @@ -352,6 +376,8 @@ if TYPE_CHECKING: CreateEntityParams, CreateEntityParamsTypedDict, CreateEntityProcessorType, + CreateEntityProviderMarkups, + CreateEntityProviderMarkupsTypedDict, CreateEntityPurchase, CreateEntityPurchaseScope, CreateEntityPurchaseTypedDict, @@ -374,26 +400,34 @@ if TYPE_CHECKING: CreateEntityUsageAlertResponseTypedDict, ) from .createfeatureop import ( - CreateFeatureCreditSchemaRequest, - CreateFeatureCreditSchemaRequestTypedDict, + CreateFeatureCreditSchemaRequestBody, + CreateFeatureCreditSchemaRequestBodyTypedDict, CreateFeatureCreditSchemaResponse, CreateFeatureCreditSchemaResponseTypedDict, - CreateFeatureDisplayRequest, - CreateFeatureDisplayRequestTypedDict, + CreateFeatureDisplayRequestBody, + CreateFeatureDisplayRequestBodyTypedDict, CreateFeatureDisplayResponse, CreateFeatureDisplayResponseTypedDict, CreateFeatureGlobals, CreateFeatureGlobalsTypedDict, + CreateFeatureModelMarkupsRequest, + CreateFeatureModelMarkupsRequestTypedDict, + CreateFeatureModelMarkupsResponse, + CreateFeatureModelMarkupsResponseTypedDict, CreateFeatureParams, CreateFeatureParamsTypedDict, + CreateFeatureProviderMarkupsRequest, + CreateFeatureProviderMarkupsRequestTypedDict, + CreateFeatureProviderMarkupsResponse, + CreateFeatureProviderMarkupsResponseTypedDict, CreateFeatureResponse, CreateFeatureResponseTypedDict, - CreateFeatureTypeRequest, + CreateFeatureTypeRequestBody, CreateFeatureTypeResponse, ) from .createplanop import ( CreatePlanAttachAction, - CreatePlanBillingMethodRequest, + CreatePlanBillingMethodRequestBody, CreatePlanBillingMethodResponse, CreatePlanConfigRequest, CreatePlanConfigRequestTypedDict, @@ -406,7 +440,7 @@ if TYPE_CHECKING: CreatePlanDurationTypeRequest, CreatePlanDurationTypeResponse, CreatePlanEnv, - CreatePlanExpiryDurationTypeRequest, + CreatePlanExpiryDurationTypeRequestBody, CreatePlanExpiryDurationTypeResponse, CreatePlanFeature, CreatePlanFeatureDisplay, @@ -419,9 +453,9 @@ if TYPE_CHECKING: CreatePlanItem, CreatePlanItemDisplay, CreatePlanItemDisplayTypedDict, - CreatePlanItemPriceIntervalRequest, - CreatePlanItemPriceRequest, - CreatePlanItemPriceRequestTypedDict, + CreatePlanItemPriceIntervalRequestBody, + CreatePlanItemPriceRequestBody, + CreatePlanItemPriceRequestBodyTypedDict, CreatePlanItemPriceResponse, CreatePlanItemPriceResponseTypedDict, CreatePlanItemTypedDict, @@ -435,34 +469,38 @@ if TYPE_CHECKING: CreatePlanPlanItemTypedDict, CreatePlanPriceDisplay, CreatePlanPriceDisplayTypedDict, - CreatePlanPriceIntervalRequest, + CreatePlanPriceIntervalRequestBody, CreatePlanPriceIntervalResponse, CreatePlanPriceItemIntervalResponse, - CreatePlanPriceRequest, - CreatePlanPriceRequestTypedDict, + CreatePlanPriceRequestBody, + CreatePlanPriceRequestBodyTypedDict, CreatePlanPriceResponse, CreatePlanPriceResponseTypedDict, CreatePlanProration, CreatePlanProrationTypedDict, - CreatePlanResetIntervalRequest, + CreatePlanResetIntervalRequestBody, CreatePlanResetIntervalResponse, - CreatePlanResetRequest, - CreatePlanResetRequestTypedDict, + CreatePlanResetRequestBody, + CreatePlanResetRequestBodyTypedDict, CreatePlanResetResponse, CreatePlanResetResponseTypedDict, CreatePlanResponse, CreatePlanResponseTypedDict, - CreatePlanRolloverRequest, - CreatePlanRolloverRequestTypedDict, + CreatePlanRolloverRequestBody, + CreatePlanRolloverRequestBodyTypedDict, CreatePlanRolloverResponse, CreatePlanRolloverResponseTypedDict, CreatePlanStatus, - CreatePlanTier, - CreatePlanTierBehaviorRequest, + CreatePlanTierBehaviorRequestBody, CreatePlanTierBehaviorResponse, - CreatePlanTierTypedDict, - CreatePlanTo, - CreatePlanToTypedDict, + CreatePlanTierRequestBody, + CreatePlanTierRequestBodyTypedDict, + CreatePlanTierResponse, + CreatePlanTierResponseTypedDict, + CreatePlanToRequestBody, + CreatePlanToRequestBodyTypedDict, + CreatePlanToResponse, + CreatePlanToResponseTypedDict, CreatePlanType, FreeTrialRequest, FreeTrialRequestTypedDict, @@ -477,51 +515,77 @@ if TYPE_CHECKING: ) from .createscheduleop import ( BillingBehavior, + CreateScheduleAddItemBillingMethod2, + CreateScheduleAddItemExpiryDurationType2, + CreateScheduleAddItemOnDecrease2, + CreateScheduleAddItemOnIncrease2, + CreateScheduleAddItemPlanItem2, + CreateScheduleAddItemPlanItem2TypedDict, + CreateScheduleAddItemPrice2, + CreateScheduleAddItemPrice2TypedDict, + CreateScheduleAddItemPriceInterval2, + CreateScheduleAddItemProration2, + CreateScheduleAddItemProration2TypedDict, + CreateScheduleAddItemReset2, + CreateScheduleAddItemReset2TypedDict, + CreateScheduleAddItemResetInterval2, + CreateScheduleAddItemRollover2, + CreateScheduleAddItemRollover2TypedDict, + CreateScheduleAddItemTier2, + CreateScheduleAddItemTier2TypedDict, + CreateScheduleAddItemTierBehavior2, CreateScheduleAttachDiscount, CreateScheduleAttachDiscountTypedDict, CreateScheduleBasePrice2, CreateScheduleBasePrice2TypedDict, - CreateScheduleBillingMethod2, CreateScheduleCode, CreateScheduleCustomize2, CreateScheduleCustomize2TypedDict, - CreateScheduleExpiryDurationType2, CreateScheduleFeatureQuantity2, CreateScheduleFeatureQuantity2TypedDict, CreateScheduleGlobals, CreateScheduleGlobalsTypedDict, + CreateScheduleIntervalRemoveItemEnum3, + CreateScheduleIntervalRemoveItemEnum4, + CreateScheduleIntervalUnion2, + CreateScheduleIntervalUnion2TypedDict, CreateScheduleInvoice, CreateScheduleInvoiceMode, CreateScheduleInvoiceModeTypedDict, CreateScheduleInvoiceTypedDict, + CreateScheduleItemBillingMethod2, + CreateScheduleItemExpiryDurationType2, + CreateScheduleItemOnDecrease2, + CreateScheduleItemOnIncrease2, + CreateScheduleItemPlanItem2, + CreateScheduleItemPlanItem2TypedDict, + CreateScheduleItemPrice2, + CreateScheduleItemPrice2TypedDict, CreateScheduleItemPriceInterval2, - CreateScheduleOnDecrease2, - CreateScheduleOnIncrease2, + CreateScheduleItemProration2, + CreateScheduleItemProration2TypedDict, + CreateScheduleItemReset2, + CreateScheduleItemReset2TypedDict, + CreateScheduleItemResetInterval2, + CreateScheduleItemRollover2, + CreateScheduleItemRollover2TypedDict, + CreateScheduleItemTier2, + CreateScheduleItemTier2TypedDict, + CreateScheduleItemTierBehavior2, CreateScheduleParams, CreateScheduleParamsTypedDict, CreateSchedulePlan2, CreateSchedulePlan2TypedDict, - CreateSchedulePlanItem2, - CreateSchedulePlanItem2TypedDict, - CreateSchedulePrice2, - CreateSchedulePrice2TypedDict, + CreateSchedulePlanItemFilter2, + CreateSchedulePlanItemFilter2TypedDict, CreateSchedulePriceInterval2, - CreateScheduleProration2, - CreateScheduleProration2TypedDict, CreateScheduleRedirectMode, + CreateScheduleRemoveItemBillingMethod2, CreateScheduleRequiredAction, CreateScheduleRequiredActionTypedDict, - CreateScheduleReset2, - CreateScheduleReset2TypedDict, - CreateScheduleResetInterval2, CreateScheduleResponse, CreateScheduleResponseTypedDict, - CreateScheduleRollover2, - CreateScheduleRollover2TypedDict, CreateScheduleStatus, - CreateScheduleTier2, - CreateScheduleTier2TypedDict, - CreateScheduleTierBehavior2, Phase, PhaseRequest2, PhaseRequest2TypedDict, @@ -549,8 +613,12 @@ if TYPE_CHECKING: CustomerFlagsType, CustomerInterval1, CustomerInterval2, + CustomerModelMarkups, + CustomerModelMarkupsTypedDict, CustomerOverageAllowed, CustomerOverageAllowedTypedDict, + CustomerProviderMarkups, + CustomerProviderMarkupsTypedDict, CustomerPurchaseLimit1, CustomerPurchaseLimit1TypedDict, CustomerPurchaseLimit2, @@ -703,6 +771,8 @@ if TYPE_CHECKING: GetCustomerInterval2, GetCustomerInvoice, GetCustomerInvoiceTypedDict, + GetCustomerModelMarkups, + GetCustomerModelMarkupsTypedDict, GetCustomerOverageAllowed, GetCustomerOverageAllowedTypedDict, GetCustomerParams, @@ -710,6 +780,8 @@ if TYPE_CHECKING: GetCustomerProcessorType, GetCustomerProcessors, GetCustomerProcessorsTypedDict, + GetCustomerProviderMarkups, + GetCustomerProviderMarkupsTypedDict, GetCustomerPurchase, GetCustomerPurchaseLimit1, GetCustomerPurchaseLimit1TypedDict, @@ -760,11 +832,15 @@ if TYPE_CHECKING: GetEntityGlobalsTypedDict, GetEntityInvoice, GetEntityInvoiceTypedDict, + GetEntityModelMarkups, + GetEntityModelMarkupsTypedDict, GetEntityOverageAllowed, GetEntityOverageAllowedTypedDict, GetEntityParams, GetEntityParamsTypedDict, GetEntityProcessorType, + GetEntityProviderMarkups, + GetEntityProviderMarkupsTypedDict, GetEntityPurchase, GetEntityPurchaseScope, GetEntityPurchaseTypedDict, @@ -788,8 +864,12 @@ if TYPE_CHECKING: GetFeatureDisplayTypedDict, GetFeatureGlobals, GetFeatureGlobalsTypedDict, + GetFeatureModelMarkups, + GetFeatureModelMarkupsTypedDict, GetFeatureParams, GetFeatureParamsTypedDict, + GetFeatureProviderMarkups, + GetFeatureProviderMarkupsTypedDict, GetFeatureResponse, GetFeatureResponseTypedDict, GetFeatureType, @@ -859,7 +939,11 @@ if TYPE_CHECKING: GetPlanRollover, GetPlanRolloverTypedDict, GetPlanStatus, + GetPlanTier, GetPlanTierBehavior, + GetPlanTierTypedDict, + GetPlanTo, + GetPlanToTypedDict, GetPlanType, ) from .getrevenuecatkeysop import ( @@ -906,6 +990,8 @@ if TYPE_CHECKING: ListCustomersInterval2, ListCustomersList, ListCustomersListTypedDict, + ListCustomersModelMarkups, + ListCustomersModelMarkupsTypedDict, ListCustomersOverageAllowed, ListCustomersOverageAllowedTypedDict, ListCustomersParams, @@ -915,6 +1001,8 @@ if TYPE_CHECKING: ListCustomersProcessor, ListCustomersProcessors, ListCustomersProcessorsTypedDict, + ListCustomersProviderMarkups, + ListCustomersProviderMarkupsTypedDict, ListCustomersPurchase, ListCustomersPurchaseLimit1, ListCustomersPurchaseLimit1TypedDict, @@ -962,6 +1050,8 @@ if TYPE_CHECKING: ListEntitiesInvoiceTypedDict, ListEntitiesList, ListEntitiesListTypedDict, + ListEntitiesModelMarkups, + ListEntitiesModelMarkupsTypedDict, ListEntitiesOverageAllowed, ListEntitiesOverageAllowedTypedDict, ListEntitiesParams, @@ -970,6 +1060,8 @@ if TYPE_CHECKING: ListEntitiesPlanTypedDict, ListEntitiesProcessor, ListEntitiesProcessorType, + ListEntitiesProviderMarkups, + ListEntitiesProviderMarkupsTypedDict, ListEntitiesPurchase, ListEntitiesPurchaseScope, ListEntitiesPurchaseTypedDict, @@ -1017,6 +1109,10 @@ if TYPE_CHECKING: ListFeaturesGlobalsTypedDict, ListFeaturesList, ListFeaturesListTypedDict, + ListFeaturesModelMarkups, + ListFeaturesModelMarkupsTypedDict, + ListFeaturesProviderMarkups, + ListFeaturesProviderMarkupsTypedDict, ListFeaturesRequest, ListFeaturesRequestTypedDict, ListFeaturesResponse, @@ -1068,7 +1164,11 @@ if TYPE_CHECKING: ListPlansRollover, ListPlansRolloverTypedDict, ListPlansStatus, + ListPlansTier, ListPlansTierBehavior, + ListPlansTierTypedDict, + ListPlansTo, + ListPlansToTypedDict, ListPlansType, ) from .multiattachop import ( @@ -1180,7 +1280,11 @@ if TYPE_CHECKING: PlanRollover, PlanRolloverTypedDict, PlanStatus, + PlanTier, PlanTierBehavior, + PlanTierTypedDict, + PlanTo, + PlanToTypedDict, PlanType, PlanTypedDict, ) @@ -1232,6 +1336,10 @@ if TYPE_CHECKING: PreviewAttachIncomingFeatureQuantity, PreviewAttachIncomingFeatureQuantityTypedDict, PreviewAttachIncomingTypedDict, + PreviewAttachIntervalRemoveItemEnum1, + PreviewAttachIntervalRemoveItemEnum2, + PreviewAttachIntervalUnion, + PreviewAttachIntervalUnionTypedDict, PreviewAttachInvoiceCredits, PreviewAttachInvoiceCreditsTypedDict, PreviewAttachInvoiceMode, @@ -1283,7 +1391,6 @@ if TYPE_CHECKING: PreviewAttachProrationBehavior, PreviewAttachRedirectMode, PreviewAttachRemoveItemBillingMethod, - PreviewAttachRemoveItemInterval, PreviewAttachResponse, PreviewAttachResponseTypedDict, PreviewAttachStatus, @@ -1427,6 +1534,10 @@ if TYPE_CHECKING: PreviewUpdateIncomingFeatureQuantity, PreviewUpdateIncomingFeatureQuantityTypedDict, PreviewUpdateIncomingTypedDict, + PreviewUpdateIntervalRemoveItemEnum1, + PreviewUpdateIntervalRemoveItemEnum2, + PreviewUpdateIntervalUnion, + PreviewUpdateIntervalUnionTypedDict, PreviewUpdateInvoiceCredits, PreviewUpdateInvoiceCreditsTypedDict, PreviewUpdateInvoiceMode, @@ -1479,7 +1590,6 @@ if TYPE_CHECKING: PreviewUpdateRecalculateBalancesTypedDict, PreviewUpdateRedirectMode, PreviewUpdateRemoveItemBillingMethod, - PreviewUpdateRemoveItemInterval, PreviewUpdateResponse, PreviewUpdateResponseTypedDict, PreviewUpdateStatus, @@ -1550,6 +1660,10 @@ if TYPE_CHECKING: SetupPaymentFreeTrialParamsTypedDict, SetupPaymentGlobals, SetupPaymentGlobalsTypedDict, + SetupPaymentIntervalRemoveItemEnum1, + SetupPaymentIntervalRemoveItemEnum2, + SetupPaymentIntervalUnion, + SetupPaymentIntervalUnionTypedDict, SetupPaymentItemBillingMethod, SetupPaymentItemExpiryDurationType, SetupPaymentItemOnDecrease, @@ -1579,7 +1693,6 @@ if TYPE_CHECKING: SetupPaymentPriceInterval, SetupPaymentProrationBehavior, SetupPaymentRemoveItemBillingMethod, - SetupPaymentRemoveItemInterval, SetupPaymentResponse, SetupPaymentResponseTypedDict, ) @@ -1601,10 +1714,10 @@ if TYPE_CHECKING: SyncRevenueCatStatus, ) from .trackop import ( - Deduction1, - Deduction1TypedDict, - Deduction2, - Deduction2TypedDict, + TrackDeduction1, + TrackDeduction1TypedDict, + TrackDeduction2, + TrackDeduction2TypedDict, TrackGlobals, TrackGlobalsTypedDict, TrackIntervalEnum1, @@ -1628,6 +1741,32 @@ if TYPE_CHECKING: TrackResponseBody2TypedDict, TrackResponseTypedDict, ) + from .tracktokensop import ( + TrackTokensDeduction1, + TrackTokensDeduction1TypedDict, + TrackTokensDeduction2, + TrackTokensDeduction2TypedDict, + TrackTokensGlobals, + TrackTokensGlobalsTypedDict, + TrackTokensIntervalEnum1, + TrackTokensIntervalEnum2, + TrackTokensIntervalUnion1, + TrackTokensIntervalUnion1TypedDict, + TrackTokensIntervalUnion2, + TrackTokensIntervalUnion2TypedDict, + TrackTokensParams, + TrackTokensParamsTypedDict, + TrackTokensReset1, + TrackTokensReset1TypedDict, + TrackTokensReset2, + TrackTokensReset2TypedDict, + TrackTokensResponse, + TrackTokensResponseBody1, + TrackTokensResponseBody1TypedDict, + TrackTokensResponseBody2, + TrackTokensResponseBody2TypedDict, + TrackTokensResponseTypedDict, + ) from .updatebalanceop import ( UpdateBalanceGlobals, UpdateBalanceGlobalsTypedDict, @@ -1661,9 +1800,11 @@ if TYPE_CHECKING: UpdateCustomerFlagsTypedDict, UpdateCustomerGlobals, UpdateCustomerGlobalsTypedDict, - UpdateCustomerIntervalRequest, + UpdateCustomerIntervalRequestBody, UpdateCustomerIntervalResponse1, UpdateCustomerIntervalResponse2, + UpdateCustomerModelMarkups, + UpdateCustomerModelMarkupsTypedDict, UpdateCustomerOverageAllowedRequest, UpdateCustomerOverageAllowedRequestTypedDict, UpdateCustomerOverageAllowedResponse, @@ -1672,6 +1813,8 @@ if TYPE_CHECKING: UpdateCustomerParamsTypedDict, UpdateCustomerProcessors, UpdateCustomerProcessorsTypedDict, + UpdateCustomerProviderMarkups, + UpdateCustomerProviderMarkupsTypedDict, UpdateCustomerPurchase, UpdateCustomerPurchaseLimitRequest, UpdateCustomerPurchaseLimitRequestTypedDict, @@ -1725,6 +1868,8 @@ if TYPE_CHECKING: UpdateEntityGlobalsTypedDict, UpdateEntityInvoice, UpdateEntityInvoiceTypedDict, + UpdateEntityModelMarkups, + UpdateEntityModelMarkupsTypedDict, UpdateEntityOverageAllowedRequest, UpdateEntityOverageAllowedRequestTypedDict, UpdateEntityOverageAllowedResponse, @@ -1732,6 +1877,8 @@ if TYPE_CHECKING: UpdateEntityParams, UpdateEntityParamsTypedDict, UpdateEntityProcessorType, + UpdateEntityProviderMarkups, + UpdateEntityProviderMarkupsTypedDict, UpdateEntityPurchase, UpdateEntityPurchaseScope, UpdateEntityPurchaseTypedDict, @@ -1754,28 +1901,36 @@ if TYPE_CHECKING: UpdateEntityUsageAlertResponseTypedDict, ) from .updatefeatureop import ( - UpdateFeatureCreditSchemaRequest, - UpdateFeatureCreditSchemaRequestTypedDict, + UpdateFeatureCreditSchemaRequestBody, + UpdateFeatureCreditSchemaRequestBodyTypedDict, UpdateFeatureCreditSchemaResponse, UpdateFeatureCreditSchemaResponseTypedDict, - UpdateFeatureDisplayRequest, - UpdateFeatureDisplayRequestTypedDict, + UpdateFeatureDisplayRequestBody, + UpdateFeatureDisplayRequestBodyTypedDict, UpdateFeatureDisplayResponse, UpdateFeatureDisplayResponseTypedDict, UpdateFeatureGlobals, UpdateFeatureGlobalsTypedDict, + UpdateFeatureModelMarkupsRequest, + UpdateFeatureModelMarkupsRequestTypedDict, + UpdateFeatureModelMarkupsResponse, + UpdateFeatureModelMarkupsResponseTypedDict, UpdateFeatureParams, UpdateFeatureParamsTypedDict, + UpdateFeatureProviderMarkupsRequest, + UpdateFeatureProviderMarkupsRequestTypedDict, + UpdateFeatureProviderMarkupsResponse, + UpdateFeatureProviderMarkupsResponseTypedDict, UpdateFeatureResponse, UpdateFeatureResponseTypedDict, - UpdateFeatureTypeRequest, + UpdateFeatureTypeRequestBody, UpdateFeatureTypeResponse, ) from .updateplanop import ( UpdatePlanAttachAction, UpdatePlanBasePrice, UpdatePlanBasePriceTypedDict, - UpdatePlanBillingMethodRequest, + UpdatePlanBillingMethodRequestBody, UpdatePlanBillingMethodResponse, UpdatePlanConfigRequest, UpdatePlanConfigRequestTypedDict, @@ -1788,7 +1943,7 @@ if TYPE_CHECKING: UpdatePlanDurationTypeRequest, UpdatePlanDurationTypeResponse, UpdatePlanEnv, - UpdatePlanExpiryDurationTypeRequest, + UpdatePlanExpiryDurationTypeRequestBody, UpdatePlanExpiryDurationTypeResponse, UpdatePlanFeature, UpdatePlanFeatureDisplay, @@ -1803,7 +1958,7 @@ if TYPE_CHECKING: UpdatePlanItem, UpdatePlanItemDisplay, UpdatePlanItemDisplayTypedDict, - UpdatePlanItemPriceIntervalRequest, + UpdatePlanItemPriceIntervalRequestBody, UpdatePlanItemPriceResponse, UpdatePlanItemPriceResponseTypedDict, UpdatePlanItemTypedDict, @@ -1817,34 +1972,38 @@ if TYPE_CHECKING: UpdatePlanPlanItemTypedDict, UpdatePlanPriceDisplay, UpdatePlanPriceDisplayTypedDict, - UpdatePlanPriceIntervalRequest, + UpdatePlanPriceIntervalRequestBody, UpdatePlanPriceIntervalResponse, UpdatePlanPriceItemIntervalResponse, - UpdatePlanPriceRequest, - UpdatePlanPriceRequestTypedDict, + UpdatePlanPriceRequestBody, + UpdatePlanPriceRequestBodyTypedDict, UpdatePlanPriceResponse, UpdatePlanPriceResponseTypedDict, UpdatePlanProration, UpdatePlanProrationTypedDict, - UpdatePlanResetIntervalRequest, + UpdatePlanResetIntervalRequestBody, UpdatePlanResetIntervalResponse, - UpdatePlanResetRequest, - UpdatePlanResetRequestTypedDict, + UpdatePlanResetRequestBody, + UpdatePlanResetRequestBodyTypedDict, UpdatePlanResetResponse, UpdatePlanResetResponseTypedDict, UpdatePlanResponse, UpdatePlanResponseTypedDict, - UpdatePlanRolloverRequest, - UpdatePlanRolloverRequestTypedDict, + UpdatePlanRolloverRequestBody, + UpdatePlanRolloverRequestBodyTypedDict, UpdatePlanRolloverResponse, UpdatePlanRolloverResponseTypedDict, UpdatePlanStatus, - UpdatePlanTier, - UpdatePlanTierBehaviorRequest, + UpdatePlanTierBehaviorRequestBody, UpdatePlanTierBehaviorResponse, - UpdatePlanTierTypedDict, - UpdatePlanTo, - UpdatePlanToTypedDict, + UpdatePlanTierRequestBody, + UpdatePlanTierRequestBodyTypedDict, + UpdatePlanTierResponse, + UpdatePlanTierResponseTypedDict, + UpdatePlanToRequestBody, + UpdatePlanToRequestBodyTypedDict, + UpdatePlanToResponse, + UpdatePlanToResponseTypedDict, UpdatePlanType, ) from . import internal @@ -1904,6 +2063,10 @@ __all__ = [ "AttachFreeTrialParamsTypedDict", "AttachGlobals", "AttachGlobalsTypedDict", + "AttachIntervalRemoveItemEnum1", + "AttachIntervalRemoveItemEnum2", + "AttachIntervalUnion", + "AttachIntervalUnionTypedDict", "AttachInvoice", "AttachInvoiceMode", "AttachInvoiceModeTypedDict", @@ -1939,7 +2102,6 @@ __all__ = [ "AttachProrationBehavior", "AttachRedirectMode", "AttachRemoveItemBillingMethod", - "AttachRemoveItemInterval", "AttachRequiredAction", "AttachRequiredActionTypedDict", "AttachResponse", @@ -1955,13 +2117,21 @@ __all__ = [ "BalanceIntervalEnum", "BalanceIntervalUnion", "BalanceIntervalUnionTypedDict", + "BalanceModelMarkups", + "BalanceModelMarkupsTypedDict", "BalancePrice", "BalancePriceTypedDict", + "BalanceProviderMarkups", + "BalanceProviderMarkupsTypedDict", "BalanceReset", "BalanceResetTypedDict", "BalanceRollover", "BalanceRolloverTypedDict", + "BalanceTier", "BalanceTierBehavior", + "BalanceTierTypedDict", + "BalanceTo", + "BalanceToTypedDict", "BalanceType", "BalanceTypedDict", "BatchTrackGlobals", @@ -2007,6 +2177,10 @@ __all__ = [ "BillingUpdateFreeTrialParamsTypedDict", "BillingUpdateGlobals", "BillingUpdateGlobalsTypedDict", + "BillingUpdateIntervalRemoveItemEnum1", + "BillingUpdateIntervalRemoveItemEnum2", + "BillingUpdateIntervalUnion", + "BillingUpdateIntervalUnionTypedDict", "BillingUpdateInvoice", "BillingUpdateInvoiceMode", "BillingUpdateInvoiceModeTypedDict", @@ -2041,7 +2215,6 @@ __all__ = [ "BillingUpdateRecalculateBalancesTypedDict", "BillingUpdateRedirectMode", "BillingUpdateRemoveItemBillingMethod", - "BillingUpdateRemoveItemInterval", "BillingUpdateRequiredAction", "BillingUpdateRequiredActionTypedDict", "BillingUpdateResponse", @@ -2077,6 +2250,10 @@ __all__ = [ "CheckItem2TypedDict", "CheckLock", "CheckLockTypedDict", + "CheckModelMarkups1", + "CheckModelMarkups1TypedDict", + "CheckModelMarkups2", + "CheckModelMarkups2TypedDict", "CheckOnDecrease1", "CheckOnDecrease2", "CheckOnEnd1", @@ -2089,6 +2266,10 @@ __all__ = [ "CheckProduct1TypedDict", "CheckProduct2", "CheckProduct2TypedDict", + "CheckProviderMarkups1", + "CheckProviderMarkups1TypedDict", + "CheckProviderMarkups2", + "CheckProviderMarkups2TypedDict", "CheckResponse", "CheckResponseBody1", "CheckResponseBody1TypedDict", @@ -2132,6 +2313,8 @@ __all__ = [ "CreateEntityGlobalsTypedDict", "CreateEntityInvoice", "CreateEntityInvoiceTypedDict", + "CreateEntityModelMarkups", + "CreateEntityModelMarkupsTypedDict", "CreateEntityOverageAllowedRequest", "CreateEntityOverageAllowedRequestTypedDict", "CreateEntityOverageAllowedResponse", @@ -2139,6 +2322,8 @@ __all__ = [ "CreateEntityParams", "CreateEntityParamsTypedDict", "CreateEntityProcessorType", + "CreateEntityProviderMarkups", + "CreateEntityProviderMarkupsTypedDict", "CreateEntityPurchase", "CreateEntityPurchaseScope", "CreateEntityPurchaseTypedDict", @@ -2159,24 +2344,32 @@ __all__ = [ "CreateEntityUsageAlertRequestBodyTypedDict", "CreateEntityUsageAlertResponse", "CreateEntityUsageAlertResponseTypedDict", - "CreateFeatureCreditSchemaRequest", - "CreateFeatureCreditSchemaRequestTypedDict", + "CreateFeatureCreditSchemaRequestBody", + "CreateFeatureCreditSchemaRequestBodyTypedDict", "CreateFeatureCreditSchemaResponse", "CreateFeatureCreditSchemaResponseTypedDict", - "CreateFeatureDisplayRequest", - "CreateFeatureDisplayRequestTypedDict", + "CreateFeatureDisplayRequestBody", + "CreateFeatureDisplayRequestBodyTypedDict", "CreateFeatureDisplayResponse", "CreateFeatureDisplayResponseTypedDict", "CreateFeatureGlobals", "CreateFeatureGlobalsTypedDict", + "CreateFeatureModelMarkupsRequest", + "CreateFeatureModelMarkupsRequestTypedDict", + "CreateFeatureModelMarkupsResponse", + "CreateFeatureModelMarkupsResponseTypedDict", "CreateFeatureParams", "CreateFeatureParamsTypedDict", + "CreateFeatureProviderMarkupsRequest", + "CreateFeatureProviderMarkupsRequestTypedDict", + "CreateFeatureProviderMarkupsResponse", + "CreateFeatureProviderMarkupsResponseTypedDict", "CreateFeatureResponse", "CreateFeatureResponseTypedDict", - "CreateFeatureTypeRequest", + "CreateFeatureTypeRequestBody", "CreateFeatureTypeResponse", "CreatePlanAttachAction", - "CreatePlanBillingMethodRequest", + "CreatePlanBillingMethodRequestBody", "CreatePlanBillingMethodResponse", "CreatePlanConfigRequest", "CreatePlanConfigRequestTypedDict", @@ -2189,7 +2382,7 @@ __all__ = [ "CreatePlanDurationTypeRequest", "CreatePlanDurationTypeResponse", "CreatePlanEnv", - "CreatePlanExpiryDurationTypeRequest", + "CreatePlanExpiryDurationTypeRequestBody", "CreatePlanExpiryDurationTypeResponse", "CreatePlanFeature", "CreatePlanFeatureDisplay", @@ -2202,9 +2395,9 @@ __all__ = [ "CreatePlanItem", "CreatePlanItemDisplay", "CreatePlanItemDisplayTypedDict", - "CreatePlanItemPriceIntervalRequest", - "CreatePlanItemPriceRequest", - "CreatePlanItemPriceRequestTypedDict", + "CreatePlanItemPriceIntervalRequestBody", + "CreatePlanItemPriceRequestBody", + "CreatePlanItemPriceRequestBodyTypedDict", "CreatePlanItemPriceResponse", "CreatePlanItemPriceResponseTypedDict", "CreatePlanItemTypedDict", @@ -2218,34 +2411,38 @@ __all__ = [ "CreatePlanPlanItemTypedDict", "CreatePlanPriceDisplay", "CreatePlanPriceDisplayTypedDict", - "CreatePlanPriceIntervalRequest", + "CreatePlanPriceIntervalRequestBody", "CreatePlanPriceIntervalResponse", "CreatePlanPriceItemIntervalResponse", - "CreatePlanPriceRequest", - "CreatePlanPriceRequestTypedDict", + "CreatePlanPriceRequestBody", + "CreatePlanPriceRequestBodyTypedDict", "CreatePlanPriceResponse", "CreatePlanPriceResponseTypedDict", "CreatePlanProration", "CreatePlanProrationTypedDict", - "CreatePlanResetIntervalRequest", + "CreatePlanResetIntervalRequestBody", "CreatePlanResetIntervalResponse", - "CreatePlanResetRequest", - "CreatePlanResetRequestTypedDict", + "CreatePlanResetRequestBody", + "CreatePlanResetRequestBodyTypedDict", "CreatePlanResetResponse", "CreatePlanResetResponseTypedDict", "CreatePlanResponse", "CreatePlanResponseTypedDict", - "CreatePlanRolloverRequest", - "CreatePlanRolloverRequestTypedDict", + "CreatePlanRolloverRequestBody", + "CreatePlanRolloverRequestBodyTypedDict", "CreatePlanRolloverResponse", "CreatePlanRolloverResponseTypedDict", "CreatePlanStatus", - "CreatePlanTier", - "CreatePlanTierBehaviorRequest", + "CreatePlanTierBehaviorRequestBody", "CreatePlanTierBehaviorResponse", - "CreatePlanTierTypedDict", - "CreatePlanTo", - "CreatePlanToTypedDict", + "CreatePlanTierRequestBody", + "CreatePlanTierRequestBodyTypedDict", + "CreatePlanTierResponse", + "CreatePlanTierResponseTypedDict", + "CreatePlanToRequestBody", + "CreatePlanToRequestBodyTypedDict", + "CreatePlanToResponse", + "CreatePlanToResponseTypedDict", "CreatePlanType", "CreateReferralCodeGlobals", "CreateReferralCodeGlobalsTypedDict", @@ -2253,51 +2450,77 @@ __all__ = [ "CreateReferralCodeParamsTypedDict", "CreateReferralCodeResponse", "CreateReferralCodeResponseTypedDict", + "CreateScheduleAddItemBillingMethod2", + "CreateScheduleAddItemExpiryDurationType2", + "CreateScheduleAddItemOnDecrease2", + "CreateScheduleAddItemOnIncrease2", + "CreateScheduleAddItemPlanItem2", + "CreateScheduleAddItemPlanItem2TypedDict", + "CreateScheduleAddItemPrice2", + "CreateScheduleAddItemPrice2TypedDict", + "CreateScheduleAddItemPriceInterval2", + "CreateScheduleAddItemProration2", + "CreateScheduleAddItemProration2TypedDict", + "CreateScheduleAddItemReset2", + "CreateScheduleAddItemReset2TypedDict", + "CreateScheduleAddItemResetInterval2", + "CreateScheduleAddItemRollover2", + "CreateScheduleAddItemRollover2TypedDict", + "CreateScheduleAddItemTier2", + "CreateScheduleAddItemTier2TypedDict", + "CreateScheduleAddItemTierBehavior2", "CreateScheduleAttachDiscount", "CreateScheduleAttachDiscountTypedDict", "CreateScheduleBasePrice2", "CreateScheduleBasePrice2TypedDict", - "CreateScheduleBillingMethod2", "CreateScheduleCode", "CreateScheduleCustomize2", "CreateScheduleCustomize2TypedDict", - "CreateScheduleExpiryDurationType2", "CreateScheduleFeatureQuantity2", "CreateScheduleFeatureQuantity2TypedDict", "CreateScheduleGlobals", "CreateScheduleGlobalsTypedDict", + "CreateScheduleIntervalRemoveItemEnum3", + "CreateScheduleIntervalRemoveItemEnum4", + "CreateScheduleIntervalUnion2", + "CreateScheduleIntervalUnion2TypedDict", "CreateScheduleInvoice", "CreateScheduleInvoiceMode", "CreateScheduleInvoiceModeTypedDict", "CreateScheduleInvoiceTypedDict", + "CreateScheduleItemBillingMethod2", + "CreateScheduleItemExpiryDurationType2", + "CreateScheduleItemOnDecrease2", + "CreateScheduleItemOnIncrease2", + "CreateScheduleItemPlanItem2", + "CreateScheduleItemPlanItem2TypedDict", + "CreateScheduleItemPrice2", + "CreateScheduleItemPrice2TypedDict", "CreateScheduleItemPriceInterval2", - "CreateScheduleOnDecrease2", - "CreateScheduleOnIncrease2", + "CreateScheduleItemProration2", + "CreateScheduleItemProration2TypedDict", + "CreateScheduleItemReset2", + "CreateScheduleItemReset2TypedDict", + "CreateScheduleItemResetInterval2", + "CreateScheduleItemRollover2", + "CreateScheduleItemRollover2TypedDict", + "CreateScheduleItemTier2", + "CreateScheduleItemTier2TypedDict", + "CreateScheduleItemTierBehavior2", "CreateScheduleParams", "CreateScheduleParamsTypedDict", "CreateSchedulePlan2", "CreateSchedulePlan2TypedDict", - "CreateSchedulePlanItem2", - "CreateSchedulePlanItem2TypedDict", - "CreateSchedulePrice2", - "CreateSchedulePrice2TypedDict", + "CreateSchedulePlanItemFilter2", + "CreateSchedulePlanItemFilter2TypedDict", "CreateSchedulePriceInterval2", - "CreateScheduleProration2", - "CreateScheduleProration2TypedDict", "CreateScheduleRedirectMode", + "CreateScheduleRemoveItemBillingMethod2", "CreateScheduleRequiredAction", "CreateScheduleRequiredActionTypedDict", - "CreateScheduleReset2", - "CreateScheduleReset2TypedDict", - "CreateScheduleResetInterval2", "CreateScheduleResponse", "CreateScheduleResponseTypedDict", - "CreateScheduleRollover2", - "CreateScheduleRollover2TypedDict", "CreateScheduleStatus", - "CreateScheduleTier2", - "CreateScheduleTier2TypedDict", - "CreateScheduleTierBehavior2", "Customer", "CustomerAutoTopup", "CustomerAutoTopupTypedDict", @@ -2338,8 +2561,12 @@ __all__ = [ "CustomerFlagsType", "CustomerInterval1", "CustomerInterval2", + "CustomerModelMarkups", + "CustomerModelMarkupsTypedDict", "CustomerOverageAllowed", "CustomerOverageAllowedTypedDict", + "CustomerProviderMarkups", + "CustomerProviderMarkupsTypedDict", "CustomerPurchaseLimit1", "CustomerPurchaseLimit1TypedDict", "CustomerPurchaseLimit2", @@ -2354,10 +2581,6 @@ __all__ = [ "CustomerTypedDict", "CustomerUsageAlert", "CustomerUsageAlertTypedDict", - "Deduction1", - "Deduction1TypedDict", - "Deduction2", - "Deduction2TypedDict", "Deductions", "DeductionsTypedDict", "DeleteBalanceGlobals", @@ -2463,6 +2686,8 @@ __all__ = [ "GetCustomerInterval2", "GetCustomerInvoice", "GetCustomerInvoiceTypedDict", + "GetCustomerModelMarkups", + "GetCustomerModelMarkupsTypedDict", "GetCustomerOverageAllowed", "GetCustomerOverageAllowedTypedDict", "GetCustomerParams", @@ -2470,6 +2695,8 @@ __all__ = [ "GetCustomerProcessorType", "GetCustomerProcessors", "GetCustomerProcessorsTypedDict", + "GetCustomerProviderMarkups", + "GetCustomerProviderMarkupsTypedDict", "GetCustomerPurchase", "GetCustomerPurchaseLimit1", "GetCustomerPurchaseLimit1TypedDict", @@ -2518,11 +2745,15 @@ __all__ = [ "GetEntityGlobalsTypedDict", "GetEntityInvoice", "GetEntityInvoiceTypedDict", + "GetEntityModelMarkups", + "GetEntityModelMarkupsTypedDict", "GetEntityOverageAllowed", "GetEntityOverageAllowedTypedDict", "GetEntityParams", "GetEntityParamsTypedDict", "GetEntityProcessorType", + "GetEntityProviderMarkups", + "GetEntityProviderMarkupsTypedDict", "GetEntityPurchase", "GetEntityPurchaseScope", "GetEntityPurchaseTypedDict", @@ -2544,8 +2775,12 @@ __all__ = [ "GetFeatureDisplayTypedDict", "GetFeatureGlobals", "GetFeatureGlobalsTypedDict", + "GetFeatureModelMarkups", + "GetFeatureModelMarkupsTypedDict", "GetFeatureParams", "GetFeatureParamsTypedDict", + "GetFeatureProviderMarkups", + "GetFeatureProviderMarkupsTypedDict", "GetFeatureResponse", "GetFeatureResponseTypedDict", "GetFeatureType", @@ -2611,7 +2846,11 @@ __all__ = [ "GetPlanRollover", "GetPlanRolloverTypedDict", "GetPlanStatus", + "GetPlanTier", "GetPlanTierBehavior", + "GetPlanTierTypedDict", + "GetPlanTo", + "GetPlanToTypedDict", "GetPlanType", "GetRevenueCatKeysApp", "GetRevenueCatKeysAppTypedDict", @@ -2659,6 +2898,8 @@ __all__ = [ "ListCustomersInterval2", "ListCustomersList", "ListCustomersListTypedDict", + "ListCustomersModelMarkups", + "ListCustomersModelMarkupsTypedDict", "ListCustomersOverageAllowed", "ListCustomersOverageAllowedTypedDict", "ListCustomersParams", @@ -2668,6 +2909,8 @@ __all__ = [ "ListCustomersProcessor", "ListCustomersProcessors", "ListCustomersProcessorsTypedDict", + "ListCustomersProviderMarkups", + "ListCustomersProviderMarkupsTypedDict", "ListCustomersPurchase", "ListCustomersPurchaseLimit1", "ListCustomersPurchaseLimit1TypedDict", @@ -2713,6 +2956,8 @@ __all__ = [ "ListEntitiesInvoiceTypedDict", "ListEntitiesList", "ListEntitiesListTypedDict", + "ListEntitiesModelMarkups", + "ListEntitiesModelMarkupsTypedDict", "ListEntitiesOverageAllowed", "ListEntitiesOverageAllowedTypedDict", "ListEntitiesParams", @@ -2721,6 +2966,8 @@ __all__ = [ "ListEntitiesPlanTypedDict", "ListEntitiesProcessor", "ListEntitiesProcessorType", + "ListEntitiesProviderMarkups", + "ListEntitiesProviderMarkupsTypedDict", "ListEntitiesPurchase", "ListEntitiesPurchaseScope", "ListEntitiesPurchaseTypedDict", @@ -2760,6 +3007,10 @@ __all__ = [ "ListFeaturesGlobalsTypedDict", "ListFeaturesList", "ListFeaturesListTypedDict", + "ListFeaturesModelMarkups", + "ListFeaturesModelMarkupsTypedDict", + "ListFeaturesProviderMarkups", + "ListFeaturesProviderMarkupsTypedDict", "ListFeaturesRequest", "ListFeaturesRequestTypedDict", "ListFeaturesResponse", @@ -2809,7 +3060,11 @@ __all__ = [ "ListPlansRollover", "ListPlansRolloverTypedDict", "ListPlansStatus", + "ListPlansTier", "ListPlansTierBehavior", + "ListPlansTierTypedDict", + "ListPlansTo", + "ListPlansToTypedDict", "ListPlansType", "MultiAttachAttachDiscount", "MultiAttachAttachDiscountTypedDict", @@ -2913,7 +3168,11 @@ __all__ = [ "PlanRollover", "PlanRolloverTypedDict", "PlanStatus", + "PlanTier", "PlanTierBehavior", + "PlanTierTypedDict", + "PlanTo", + "PlanToTypedDict", "PlanType", "PlanTypedDict", "Preview1", @@ -2967,6 +3226,10 @@ __all__ = [ "PreviewAttachIncomingFeatureQuantity", "PreviewAttachIncomingFeatureQuantityTypedDict", "PreviewAttachIncomingTypedDict", + "PreviewAttachIntervalRemoveItemEnum1", + "PreviewAttachIntervalRemoveItemEnum2", + "PreviewAttachIntervalUnion", + "PreviewAttachIntervalUnionTypedDict", "PreviewAttachInvoiceCredits", "PreviewAttachInvoiceCreditsTypedDict", "PreviewAttachInvoiceMode", @@ -3018,7 +3281,6 @@ __all__ = [ "PreviewAttachProrationBehavior", "PreviewAttachRedirectMode", "PreviewAttachRemoveItemBillingMethod", - "PreviewAttachRemoveItemInterval", "PreviewAttachResponse", "PreviewAttachResponseTypedDict", "PreviewAttachStatus", @@ -3157,6 +3419,10 @@ __all__ = [ "PreviewUpdateIncomingFeatureQuantity", "PreviewUpdateIncomingFeatureQuantityTypedDict", "PreviewUpdateIncomingTypedDict", + "PreviewUpdateIntervalRemoveItemEnum1", + "PreviewUpdateIntervalRemoveItemEnum2", + "PreviewUpdateIntervalUnion", + "PreviewUpdateIntervalUnionTypedDict", "PreviewUpdateInvoiceCredits", "PreviewUpdateInvoiceCreditsTypedDict", "PreviewUpdateInvoiceMode", @@ -3209,7 +3475,6 @@ __all__ = [ "PreviewUpdateRecalculateBalancesTypedDict", "PreviewUpdateRedirectMode", "PreviewUpdateRemoveItemBillingMethod", - "PreviewUpdateRemoveItemInterval", "PreviewUpdateResponse", "PreviewUpdateResponseTypedDict", "PreviewUpdateStatus", @@ -3306,6 +3571,10 @@ __all__ = [ "SetupPaymentFreeTrialParamsTypedDict", "SetupPaymentGlobals", "SetupPaymentGlobalsTypedDict", + "SetupPaymentIntervalRemoveItemEnum1", + "SetupPaymentIntervalRemoveItemEnum2", + "SetupPaymentIntervalUnion", + "SetupPaymentIntervalUnionTypedDict", "SetupPaymentItemBillingMethod", "SetupPaymentItemExpiryDurationType", "SetupPaymentItemOnDecrease", @@ -3335,7 +3604,6 @@ __all__ = [ "SetupPaymentPriceInterval", "SetupPaymentProrationBehavior", "SetupPaymentRemoveItemBillingMethod", - "SetupPaymentRemoveItemInterval", "SetupPaymentResponse", "SetupPaymentResponseTypedDict", "StorePush", @@ -3358,6 +3626,10 @@ __all__ = [ "SyncRevenueCatStatus", "Total", "TotalTypedDict", + "TrackDeduction1", + "TrackDeduction1TypedDict", + "TrackDeduction2", + "TrackDeduction2TypedDict", "TrackGlobals", "TrackGlobalsTypedDict", "TrackIntervalEnum1", @@ -3380,6 +3652,30 @@ __all__ = [ "TrackResponseBody2", "TrackResponseBody2TypedDict", "TrackResponseTypedDict", + "TrackTokensDeduction1", + "TrackTokensDeduction1TypedDict", + "TrackTokensDeduction2", + "TrackTokensDeduction2TypedDict", + "TrackTokensGlobals", + "TrackTokensGlobalsTypedDict", + "TrackTokensIntervalEnum1", + "TrackTokensIntervalEnum2", + "TrackTokensIntervalUnion1", + "TrackTokensIntervalUnion1TypedDict", + "TrackTokensIntervalUnion2", + "TrackTokensIntervalUnion2TypedDict", + "TrackTokensParams", + "TrackTokensParamsTypedDict", + "TrackTokensReset1", + "TrackTokensReset1TypedDict", + "TrackTokensReset2", + "TrackTokensReset2TypedDict", + "TrackTokensResponse", + "TrackTokensResponseBody1", + "TrackTokensResponseBody1TypedDict", + "TrackTokensResponseBody2", + "TrackTokensResponseBody2TypedDict", + "TrackTokensResponseTypedDict", "TrialsUsed", "TrialsUsedTypedDict", "UpdateBalanceGlobals", @@ -3412,9 +3708,11 @@ __all__ = [ "UpdateCustomerFlagsTypedDict", "UpdateCustomerGlobals", "UpdateCustomerGlobalsTypedDict", - "UpdateCustomerIntervalRequest", + "UpdateCustomerIntervalRequestBody", "UpdateCustomerIntervalResponse1", "UpdateCustomerIntervalResponse2", + "UpdateCustomerModelMarkups", + "UpdateCustomerModelMarkupsTypedDict", "UpdateCustomerOverageAllowedRequest", "UpdateCustomerOverageAllowedRequestTypedDict", "UpdateCustomerOverageAllowedResponse", @@ -3423,6 +3721,8 @@ __all__ = [ "UpdateCustomerParamsTypedDict", "UpdateCustomerProcessors", "UpdateCustomerProcessorsTypedDict", + "UpdateCustomerProviderMarkups", + "UpdateCustomerProviderMarkupsTypedDict", "UpdateCustomerPurchase", "UpdateCustomerPurchaseLimitRequest", "UpdateCustomerPurchaseLimitRequestTypedDict", @@ -3474,6 +3774,8 @@ __all__ = [ "UpdateEntityGlobalsTypedDict", "UpdateEntityInvoice", "UpdateEntityInvoiceTypedDict", + "UpdateEntityModelMarkups", + "UpdateEntityModelMarkupsTypedDict", "UpdateEntityOverageAllowedRequest", "UpdateEntityOverageAllowedRequestTypedDict", "UpdateEntityOverageAllowedResponse", @@ -3481,6 +3783,8 @@ __all__ = [ "UpdateEntityParams", "UpdateEntityParamsTypedDict", "UpdateEntityProcessorType", + "UpdateEntityProviderMarkups", + "UpdateEntityProviderMarkupsTypedDict", "UpdateEntityPurchase", "UpdateEntityPurchaseScope", "UpdateEntityPurchaseTypedDict", @@ -3501,26 +3805,34 @@ __all__ = [ "UpdateEntityUsageAlertRequestBodyTypedDict", "UpdateEntityUsageAlertResponse", "UpdateEntityUsageAlertResponseTypedDict", - "UpdateFeatureCreditSchemaRequest", - "UpdateFeatureCreditSchemaRequestTypedDict", + "UpdateFeatureCreditSchemaRequestBody", + "UpdateFeatureCreditSchemaRequestBodyTypedDict", "UpdateFeatureCreditSchemaResponse", "UpdateFeatureCreditSchemaResponseTypedDict", - "UpdateFeatureDisplayRequest", - "UpdateFeatureDisplayRequestTypedDict", + "UpdateFeatureDisplayRequestBody", + "UpdateFeatureDisplayRequestBodyTypedDict", "UpdateFeatureDisplayResponse", "UpdateFeatureDisplayResponseTypedDict", "UpdateFeatureGlobals", "UpdateFeatureGlobalsTypedDict", + "UpdateFeatureModelMarkupsRequest", + "UpdateFeatureModelMarkupsRequestTypedDict", + "UpdateFeatureModelMarkupsResponse", + "UpdateFeatureModelMarkupsResponseTypedDict", "UpdateFeatureParams", "UpdateFeatureParamsTypedDict", + "UpdateFeatureProviderMarkupsRequest", + "UpdateFeatureProviderMarkupsRequestTypedDict", + "UpdateFeatureProviderMarkupsResponse", + "UpdateFeatureProviderMarkupsResponseTypedDict", "UpdateFeatureResponse", "UpdateFeatureResponseTypedDict", - "UpdateFeatureTypeRequest", + "UpdateFeatureTypeRequestBody", "UpdateFeatureTypeResponse", "UpdatePlanAttachAction", "UpdatePlanBasePrice", "UpdatePlanBasePriceTypedDict", - "UpdatePlanBillingMethodRequest", + "UpdatePlanBillingMethodRequestBody", "UpdatePlanBillingMethodResponse", "UpdatePlanConfigRequest", "UpdatePlanConfigRequestTypedDict", @@ -3533,7 +3845,7 @@ __all__ = [ "UpdatePlanDurationTypeRequest", "UpdatePlanDurationTypeResponse", "UpdatePlanEnv", - "UpdatePlanExpiryDurationTypeRequest", + "UpdatePlanExpiryDurationTypeRequestBody", "UpdatePlanExpiryDurationTypeResponse", "UpdatePlanFeature", "UpdatePlanFeatureDisplay", @@ -3548,7 +3860,7 @@ __all__ = [ "UpdatePlanItem", "UpdatePlanItemDisplay", "UpdatePlanItemDisplayTypedDict", - "UpdatePlanItemPriceIntervalRequest", + "UpdatePlanItemPriceIntervalRequestBody", "UpdatePlanItemPriceResponse", "UpdatePlanItemPriceResponseTypedDict", "UpdatePlanItemTypedDict", @@ -3562,34 +3874,38 @@ __all__ = [ "UpdatePlanPlanItemTypedDict", "UpdatePlanPriceDisplay", "UpdatePlanPriceDisplayTypedDict", - "UpdatePlanPriceIntervalRequest", + "UpdatePlanPriceIntervalRequestBody", "UpdatePlanPriceIntervalResponse", "UpdatePlanPriceItemIntervalResponse", - "UpdatePlanPriceRequest", - "UpdatePlanPriceRequestTypedDict", + "UpdatePlanPriceRequestBody", + "UpdatePlanPriceRequestBodyTypedDict", "UpdatePlanPriceResponse", "UpdatePlanPriceResponseTypedDict", "UpdatePlanProration", "UpdatePlanProrationTypedDict", - "UpdatePlanResetIntervalRequest", + "UpdatePlanResetIntervalRequestBody", "UpdatePlanResetIntervalResponse", - "UpdatePlanResetRequest", - "UpdatePlanResetRequestTypedDict", + "UpdatePlanResetRequestBody", + "UpdatePlanResetRequestBodyTypedDict", "UpdatePlanResetResponse", "UpdatePlanResetResponseTypedDict", "UpdatePlanResponse", "UpdatePlanResponseTypedDict", - "UpdatePlanRolloverRequest", - "UpdatePlanRolloverRequestTypedDict", + "UpdatePlanRolloverRequestBody", + "UpdatePlanRolloverRequestBodyTypedDict", "UpdatePlanRolloverResponse", "UpdatePlanRolloverResponseTypedDict", "UpdatePlanStatus", - "UpdatePlanTier", - "UpdatePlanTierBehaviorRequest", + "UpdatePlanTierBehaviorRequestBody", "UpdatePlanTierBehaviorResponse", - "UpdatePlanTierTypedDict", - "UpdatePlanTo", - "UpdatePlanToTypedDict", + "UpdatePlanTierRequestBody", + "UpdatePlanTierRequestBodyTypedDict", + "UpdatePlanTierResponse", + "UpdatePlanTierResponseTypedDict", + "UpdatePlanToRequestBody", + "UpdatePlanToRequestBodyTypedDict", + "UpdatePlanToResponse", + "UpdatePlanToResponseTypedDict", "UpdatePlanType", "UpdateSubscriptionParams", "UpdateSubscriptionParamsTypedDict", @@ -3657,6 +3973,10 @@ _dynamic_imports: dict[str, str] = { "AttachFreeTrialParamsTypedDict": ".attachop", "AttachGlobals": ".attachop", "AttachGlobalsTypedDict": ".attachop", + "AttachIntervalRemoveItemEnum1": ".attachop", + "AttachIntervalRemoveItemEnum2": ".attachop", + "AttachIntervalUnion": ".attachop", + "AttachIntervalUnionTypedDict": ".attachop", "AttachInvoice": ".attachop", "AttachInvoiceMode": ".attachop", "AttachInvoiceModeTypedDict": ".attachop", @@ -3692,7 +4012,6 @@ _dynamic_imports: dict[str, str] = { "AttachProrationBehavior": ".attachop", "AttachRedirectMode": ".attachop", "AttachRemoveItemBillingMethod": ".attachop", - "AttachRemoveItemInterval": ".attachop", "AttachRequiredAction": ".attachop", "AttachRequiredActionTypedDict": ".attachop", "AttachResponse": ".attachop", @@ -3708,13 +4027,21 @@ _dynamic_imports: dict[str, str] = { "BalanceIntervalEnum": ".balance", "BalanceIntervalUnion": ".balance", "BalanceIntervalUnionTypedDict": ".balance", + "BalanceModelMarkups": ".balance", + "BalanceModelMarkupsTypedDict": ".balance", "BalancePrice": ".balance", "BalancePriceTypedDict": ".balance", + "BalanceProviderMarkups": ".balance", + "BalanceProviderMarkupsTypedDict": ".balance", "BalanceReset": ".balance", "BalanceResetTypedDict": ".balance", "BalanceRollover": ".balance", "BalanceRolloverTypedDict": ".balance", + "BalanceTier": ".balance", "BalanceTierBehavior": ".balance", + "BalanceTierTypedDict": ".balance", + "BalanceTo": ".balance", + "BalanceToTypedDict": ".balance", "BalanceType": ".balance", "BalanceTypedDict": ".balance", "Breakdown": ".balance", @@ -3763,6 +4090,10 @@ _dynamic_imports: dict[str, str] = { "BillingUpdateFreeTrialParamsTypedDict": ".billingupdateop", "BillingUpdateGlobals": ".billingupdateop", "BillingUpdateGlobalsTypedDict": ".billingupdateop", + "BillingUpdateIntervalRemoveItemEnum1": ".billingupdateop", + "BillingUpdateIntervalRemoveItemEnum2": ".billingupdateop", + "BillingUpdateIntervalUnion": ".billingupdateop", + "BillingUpdateIntervalUnionTypedDict": ".billingupdateop", "BillingUpdateInvoice": ".billingupdateop", "BillingUpdateInvoiceMode": ".billingupdateop", "BillingUpdateInvoiceModeTypedDict": ".billingupdateop", @@ -3797,7 +4128,6 @@ _dynamic_imports: dict[str, str] = { "BillingUpdateRecalculateBalancesTypedDict": ".billingupdateop", "BillingUpdateRedirectMode": ".billingupdateop", "BillingUpdateRemoveItemBillingMethod": ".billingupdateop", - "BillingUpdateRemoveItemInterval": ".billingupdateop", "BillingUpdateRequiredAction": ".billingupdateop", "BillingUpdateRequiredActionTypedDict": ".billingupdateop", "BillingUpdateResponse": ".billingupdateop", @@ -3832,6 +4162,10 @@ _dynamic_imports: dict[str, str] = { "CheckItem2TypedDict": ".checkop", "CheckLock": ".checkop", "CheckLockTypedDict": ".checkop", + "CheckModelMarkups1": ".checkop", + "CheckModelMarkups1TypedDict": ".checkop", + "CheckModelMarkups2": ".checkop", + "CheckModelMarkups2TypedDict": ".checkop", "CheckOnDecrease1": ".checkop", "CheckOnDecrease2": ".checkop", "CheckOnEnd1": ".checkop", @@ -3844,6 +4178,10 @@ _dynamic_imports: dict[str, str] = { "CheckProduct1TypedDict": ".checkop", "CheckProduct2": ".checkop", "CheckProduct2TypedDict": ".checkop", + "CheckProviderMarkups1": ".checkop", + "CheckProviderMarkups1TypedDict": ".checkop", + "CheckProviderMarkups2": ".checkop", + "CheckProviderMarkups2TypedDict": ".checkop", "CheckResponse": ".checkop", "CheckResponseBody1": ".checkop", "CheckResponseBody1TypedDict": ".checkop", @@ -3925,6 +4263,8 @@ _dynamic_imports: dict[str, str] = { "CreateEntityGlobalsTypedDict": ".createentityop", "CreateEntityInvoice": ".createentityop", "CreateEntityInvoiceTypedDict": ".createentityop", + "CreateEntityModelMarkups": ".createentityop", + "CreateEntityModelMarkupsTypedDict": ".createentityop", "CreateEntityOverageAllowedRequest": ".createentityop", "CreateEntityOverageAllowedRequestTypedDict": ".createentityop", "CreateEntityOverageAllowedResponse": ".createentityop", @@ -3932,6 +4272,8 @@ _dynamic_imports: dict[str, str] = { "CreateEntityParams": ".createentityop", "CreateEntityParamsTypedDict": ".createentityop", "CreateEntityProcessorType": ".createentityop", + "CreateEntityProviderMarkups": ".createentityop", + "CreateEntityProviderMarkupsTypedDict": ".createentityop", "CreateEntityPurchase": ".createentityop", "CreateEntityPurchaseScope": ".createentityop", "CreateEntityPurchaseTypedDict": ".createentityop", @@ -3952,24 +4294,32 @@ _dynamic_imports: dict[str, str] = { "CreateEntityUsageAlertRequestBodyTypedDict": ".createentityop", "CreateEntityUsageAlertResponse": ".createentityop", "CreateEntityUsageAlertResponseTypedDict": ".createentityop", - "CreateFeatureCreditSchemaRequest": ".createfeatureop", - "CreateFeatureCreditSchemaRequestTypedDict": ".createfeatureop", + "CreateFeatureCreditSchemaRequestBody": ".createfeatureop", + "CreateFeatureCreditSchemaRequestBodyTypedDict": ".createfeatureop", "CreateFeatureCreditSchemaResponse": ".createfeatureop", "CreateFeatureCreditSchemaResponseTypedDict": ".createfeatureop", - "CreateFeatureDisplayRequest": ".createfeatureop", - "CreateFeatureDisplayRequestTypedDict": ".createfeatureop", + "CreateFeatureDisplayRequestBody": ".createfeatureop", + "CreateFeatureDisplayRequestBodyTypedDict": ".createfeatureop", "CreateFeatureDisplayResponse": ".createfeatureop", "CreateFeatureDisplayResponseTypedDict": ".createfeatureop", "CreateFeatureGlobals": ".createfeatureop", "CreateFeatureGlobalsTypedDict": ".createfeatureop", + "CreateFeatureModelMarkupsRequest": ".createfeatureop", + "CreateFeatureModelMarkupsRequestTypedDict": ".createfeatureop", + "CreateFeatureModelMarkupsResponse": ".createfeatureop", + "CreateFeatureModelMarkupsResponseTypedDict": ".createfeatureop", "CreateFeatureParams": ".createfeatureop", "CreateFeatureParamsTypedDict": ".createfeatureop", + "CreateFeatureProviderMarkupsRequest": ".createfeatureop", + "CreateFeatureProviderMarkupsRequestTypedDict": ".createfeatureop", + "CreateFeatureProviderMarkupsResponse": ".createfeatureop", + "CreateFeatureProviderMarkupsResponseTypedDict": ".createfeatureop", "CreateFeatureResponse": ".createfeatureop", "CreateFeatureResponseTypedDict": ".createfeatureop", - "CreateFeatureTypeRequest": ".createfeatureop", + "CreateFeatureTypeRequestBody": ".createfeatureop", "CreateFeatureTypeResponse": ".createfeatureop", "CreatePlanAttachAction": ".createplanop", - "CreatePlanBillingMethodRequest": ".createplanop", + "CreatePlanBillingMethodRequestBody": ".createplanop", "CreatePlanBillingMethodResponse": ".createplanop", "CreatePlanConfigRequest": ".createplanop", "CreatePlanConfigRequestTypedDict": ".createplanop", @@ -3982,7 +4332,7 @@ _dynamic_imports: dict[str, str] = { "CreatePlanDurationTypeRequest": ".createplanop", "CreatePlanDurationTypeResponse": ".createplanop", "CreatePlanEnv": ".createplanop", - "CreatePlanExpiryDurationTypeRequest": ".createplanop", + "CreatePlanExpiryDurationTypeRequestBody": ".createplanop", "CreatePlanExpiryDurationTypeResponse": ".createplanop", "CreatePlanFeature": ".createplanop", "CreatePlanFeatureDisplay": ".createplanop", @@ -3995,9 +4345,9 @@ _dynamic_imports: dict[str, str] = { "CreatePlanItem": ".createplanop", "CreatePlanItemDisplay": ".createplanop", "CreatePlanItemDisplayTypedDict": ".createplanop", - "CreatePlanItemPriceIntervalRequest": ".createplanop", - "CreatePlanItemPriceRequest": ".createplanop", - "CreatePlanItemPriceRequestTypedDict": ".createplanop", + "CreatePlanItemPriceIntervalRequestBody": ".createplanop", + "CreatePlanItemPriceRequestBody": ".createplanop", + "CreatePlanItemPriceRequestBodyTypedDict": ".createplanop", "CreatePlanItemPriceResponse": ".createplanop", "CreatePlanItemPriceResponseTypedDict": ".createplanop", "CreatePlanItemTypedDict": ".createplanop", @@ -4011,34 +4361,38 @@ _dynamic_imports: dict[str, str] = { "CreatePlanPlanItemTypedDict": ".createplanop", "CreatePlanPriceDisplay": ".createplanop", "CreatePlanPriceDisplayTypedDict": ".createplanop", - "CreatePlanPriceIntervalRequest": ".createplanop", + "CreatePlanPriceIntervalRequestBody": ".createplanop", "CreatePlanPriceIntervalResponse": ".createplanop", "CreatePlanPriceItemIntervalResponse": ".createplanop", - "CreatePlanPriceRequest": ".createplanop", - "CreatePlanPriceRequestTypedDict": ".createplanop", + "CreatePlanPriceRequestBody": ".createplanop", + "CreatePlanPriceRequestBodyTypedDict": ".createplanop", "CreatePlanPriceResponse": ".createplanop", "CreatePlanPriceResponseTypedDict": ".createplanop", "CreatePlanProration": ".createplanop", "CreatePlanProrationTypedDict": ".createplanop", - "CreatePlanResetIntervalRequest": ".createplanop", + "CreatePlanResetIntervalRequestBody": ".createplanop", "CreatePlanResetIntervalResponse": ".createplanop", - "CreatePlanResetRequest": ".createplanop", - "CreatePlanResetRequestTypedDict": ".createplanop", + "CreatePlanResetRequestBody": ".createplanop", + "CreatePlanResetRequestBodyTypedDict": ".createplanop", "CreatePlanResetResponse": ".createplanop", "CreatePlanResetResponseTypedDict": ".createplanop", "CreatePlanResponse": ".createplanop", "CreatePlanResponseTypedDict": ".createplanop", - "CreatePlanRolloverRequest": ".createplanop", - "CreatePlanRolloverRequestTypedDict": ".createplanop", + "CreatePlanRolloverRequestBody": ".createplanop", + "CreatePlanRolloverRequestBodyTypedDict": ".createplanop", "CreatePlanRolloverResponse": ".createplanop", "CreatePlanRolloverResponseTypedDict": ".createplanop", "CreatePlanStatus": ".createplanop", - "CreatePlanTier": ".createplanop", - "CreatePlanTierBehaviorRequest": ".createplanop", + "CreatePlanTierBehaviorRequestBody": ".createplanop", "CreatePlanTierBehaviorResponse": ".createplanop", - "CreatePlanTierTypedDict": ".createplanop", - "CreatePlanTo": ".createplanop", - "CreatePlanToTypedDict": ".createplanop", + "CreatePlanTierRequestBody": ".createplanop", + "CreatePlanTierRequestBodyTypedDict": ".createplanop", + "CreatePlanTierResponse": ".createplanop", + "CreatePlanTierResponseTypedDict": ".createplanop", + "CreatePlanToRequestBody": ".createplanop", + "CreatePlanToRequestBodyTypedDict": ".createplanop", + "CreatePlanToResponse": ".createplanop", + "CreatePlanToResponseTypedDict": ".createplanop", "CreatePlanType": ".createplanop", "FreeTrialRequest": ".createplanop", "FreeTrialRequestTypedDict": ".createplanop", @@ -4049,51 +4403,77 @@ _dynamic_imports: dict[str, str] = { "CreateReferralCodeResponse": ".createreferralcodeop", "CreateReferralCodeResponseTypedDict": ".createreferralcodeop", "BillingBehavior": ".createscheduleop", + "CreateScheduleAddItemBillingMethod2": ".createscheduleop", + "CreateScheduleAddItemExpiryDurationType2": ".createscheduleop", + "CreateScheduleAddItemOnDecrease2": ".createscheduleop", + "CreateScheduleAddItemOnIncrease2": ".createscheduleop", + "CreateScheduleAddItemPlanItem2": ".createscheduleop", + "CreateScheduleAddItemPlanItem2TypedDict": ".createscheduleop", + "CreateScheduleAddItemPrice2": ".createscheduleop", + "CreateScheduleAddItemPrice2TypedDict": ".createscheduleop", + "CreateScheduleAddItemPriceInterval2": ".createscheduleop", + "CreateScheduleAddItemProration2": ".createscheduleop", + "CreateScheduleAddItemProration2TypedDict": ".createscheduleop", + "CreateScheduleAddItemReset2": ".createscheduleop", + "CreateScheduleAddItemReset2TypedDict": ".createscheduleop", + "CreateScheduleAddItemResetInterval2": ".createscheduleop", + "CreateScheduleAddItemRollover2": ".createscheduleop", + "CreateScheduleAddItemRollover2TypedDict": ".createscheduleop", + "CreateScheduleAddItemTier2": ".createscheduleop", + "CreateScheduleAddItemTier2TypedDict": ".createscheduleop", + "CreateScheduleAddItemTierBehavior2": ".createscheduleop", "CreateScheduleAttachDiscount": ".createscheduleop", "CreateScheduleAttachDiscountTypedDict": ".createscheduleop", "CreateScheduleBasePrice2": ".createscheduleop", "CreateScheduleBasePrice2TypedDict": ".createscheduleop", - "CreateScheduleBillingMethod2": ".createscheduleop", "CreateScheduleCode": ".createscheduleop", "CreateScheduleCustomize2": ".createscheduleop", "CreateScheduleCustomize2TypedDict": ".createscheduleop", - "CreateScheduleExpiryDurationType2": ".createscheduleop", "CreateScheduleFeatureQuantity2": ".createscheduleop", "CreateScheduleFeatureQuantity2TypedDict": ".createscheduleop", "CreateScheduleGlobals": ".createscheduleop", "CreateScheduleGlobalsTypedDict": ".createscheduleop", + "CreateScheduleIntervalRemoveItemEnum3": ".createscheduleop", + "CreateScheduleIntervalRemoveItemEnum4": ".createscheduleop", + "CreateScheduleIntervalUnion2": ".createscheduleop", + "CreateScheduleIntervalUnion2TypedDict": ".createscheduleop", "CreateScheduleInvoice": ".createscheduleop", "CreateScheduleInvoiceMode": ".createscheduleop", "CreateScheduleInvoiceModeTypedDict": ".createscheduleop", "CreateScheduleInvoiceTypedDict": ".createscheduleop", + "CreateScheduleItemBillingMethod2": ".createscheduleop", + "CreateScheduleItemExpiryDurationType2": ".createscheduleop", + "CreateScheduleItemOnDecrease2": ".createscheduleop", + "CreateScheduleItemOnIncrease2": ".createscheduleop", + "CreateScheduleItemPlanItem2": ".createscheduleop", + "CreateScheduleItemPlanItem2TypedDict": ".createscheduleop", + "CreateScheduleItemPrice2": ".createscheduleop", + "CreateScheduleItemPrice2TypedDict": ".createscheduleop", "CreateScheduleItemPriceInterval2": ".createscheduleop", - "CreateScheduleOnDecrease2": ".createscheduleop", - "CreateScheduleOnIncrease2": ".createscheduleop", + "CreateScheduleItemProration2": ".createscheduleop", + "CreateScheduleItemProration2TypedDict": ".createscheduleop", + "CreateScheduleItemReset2": ".createscheduleop", + "CreateScheduleItemReset2TypedDict": ".createscheduleop", + "CreateScheduleItemResetInterval2": ".createscheduleop", + "CreateScheduleItemRollover2": ".createscheduleop", + "CreateScheduleItemRollover2TypedDict": ".createscheduleop", + "CreateScheduleItemTier2": ".createscheduleop", + "CreateScheduleItemTier2TypedDict": ".createscheduleop", + "CreateScheduleItemTierBehavior2": ".createscheduleop", "CreateScheduleParams": ".createscheduleop", "CreateScheduleParamsTypedDict": ".createscheduleop", "CreateSchedulePlan2": ".createscheduleop", "CreateSchedulePlan2TypedDict": ".createscheduleop", - "CreateSchedulePlanItem2": ".createscheduleop", - "CreateSchedulePlanItem2TypedDict": ".createscheduleop", - "CreateSchedulePrice2": ".createscheduleop", - "CreateSchedulePrice2TypedDict": ".createscheduleop", + "CreateSchedulePlanItemFilter2": ".createscheduleop", + "CreateSchedulePlanItemFilter2TypedDict": ".createscheduleop", "CreateSchedulePriceInterval2": ".createscheduleop", - "CreateScheduleProration2": ".createscheduleop", - "CreateScheduleProration2TypedDict": ".createscheduleop", "CreateScheduleRedirectMode": ".createscheduleop", + "CreateScheduleRemoveItemBillingMethod2": ".createscheduleop", "CreateScheduleRequiredAction": ".createscheduleop", "CreateScheduleRequiredActionTypedDict": ".createscheduleop", - "CreateScheduleReset2": ".createscheduleop", - "CreateScheduleReset2TypedDict": ".createscheduleop", - "CreateScheduleResetInterval2": ".createscheduleop", "CreateScheduleResponse": ".createscheduleop", "CreateScheduleResponseTypedDict": ".createscheduleop", - "CreateScheduleRollover2": ".createscheduleop", - "CreateScheduleRollover2TypedDict": ".createscheduleop", "CreateScheduleStatus": ".createscheduleop", - "CreateScheduleTier2": ".createscheduleop", - "CreateScheduleTier2TypedDict": ".createscheduleop", - "CreateScheduleTierBehavior2": ".createscheduleop", "Phase": ".createscheduleop", "PhaseRequest2": ".createscheduleop", "PhaseRequest2TypedDict": ".createscheduleop", @@ -4119,8 +4499,12 @@ _dynamic_imports: dict[str, str] = { "CustomerFlagsType": ".customer", "CustomerInterval1": ".customer", "CustomerInterval2": ".customer", + "CustomerModelMarkups": ".customer", + "CustomerModelMarkupsTypedDict": ".customer", "CustomerOverageAllowed": ".customer", "CustomerOverageAllowedTypedDict": ".customer", + "CustomerProviderMarkups": ".customer", + "CustomerProviderMarkupsTypedDict": ".customer", "CustomerPurchaseLimit1": ".customer", "CustomerPurchaseLimit1TypedDict": ".customer", "CustomerPurchaseLimit2": ".customer", @@ -4257,6 +4641,8 @@ _dynamic_imports: dict[str, str] = { "GetCustomerInterval2": ".getcustomerop", "GetCustomerInvoice": ".getcustomerop", "GetCustomerInvoiceTypedDict": ".getcustomerop", + "GetCustomerModelMarkups": ".getcustomerop", + "GetCustomerModelMarkupsTypedDict": ".getcustomerop", "GetCustomerOverageAllowed": ".getcustomerop", "GetCustomerOverageAllowedTypedDict": ".getcustomerop", "GetCustomerParams": ".getcustomerop", @@ -4264,6 +4650,8 @@ _dynamic_imports: dict[str, str] = { "GetCustomerProcessorType": ".getcustomerop", "GetCustomerProcessors": ".getcustomerop", "GetCustomerProcessorsTypedDict": ".getcustomerop", + "GetCustomerProviderMarkups": ".getcustomerop", + "GetCustomerProviderMarkupsTypedDict": ".getcustomerop", "GetCustomerPurchase": ".getcustomerop", "GetCustomerPurchaseLimit1": ".getcustomerop", "GetCustomerPurchaseLimit1TypedDict": ".getcustomerop", @@ -4312,11 +4700,15 @@ _dynamic_imports: dict[str, str] = { "GetEntityGlobalsTypedDict": ".getentityop", "GetEntityInvoice": ".getentityop", "GetEntityInvoiceTypedDict": ".getentityop", + "GetEntityModelMarkups": ".getentityop", + "GetEntityModelMarkupsTypedDict": ".getentityop", "GetEntityOverageAllowed": ".getentityop", "GetEntityOverageAllowedTypedDict": ".getentityop", "GetEntityParams": ".getentityop", "GetEntityParamsTypedDict": ".getentityop", "GetEntityProcessorType": ".getentityop", + "GetEntityProviderMarkups": ".getentityop", + "GetEntityProviderMarkupsTypedDict": ".getentityop", "GetEntityPurchase": ".getentityop", "GetEntityPurchaseScope": ".getentityop", "GetEntityPurchaseTypedDict": ".getentityop", @@ -4338,8 +4730,12 @@ _dynamic_imports: dict[str, str] = { "GetFeatureDisplayTypedDict": ".getfeatureop", "GetFeatureGlobals": ".getfeatureop", "GetFeatureGlobalsTypedDict": ".getfeatureop", + "GetFeatureModelMarkups": ".getfeatureop", + "GetFeatureModelMarkupsTypedDict": ".getfeatureop", "GetFeatureParams": ".getfeatureop", "GetFeatureParamsTypedDict": ".getfeatureop", + "GetFeatureProviderMarkups": ".getfeatureop", + "GetFeatureProviderMarkupsTypedDict": ".getfeatureop", "GetFeatureResponse": ".getfeatureop", "GetFeatureResponseTypedDict": ".getfeatureop", "GetFeatureType": ".getfeatureop", @@ -4405,7 +4801,11 @@ _dynamic_imports: dict[str, str] = { "GetPlanRollover": ".getplanop", "GetPlanRolloverTypedDict": ".getplanop", "GetPlanStatus": ".getplanop", + "GetPlanTier": ".getplanop", "GetPlanTierBehavior": ".getplanop", + "GetPlanTierTypedDict": ".getplanop", + "GetPlanTo": ".getplanop", + "GetPlanToTypedDict": ".getplanop", "GetPlanType": ".getplanop", "APIKey": ".getrevenuecatkeysop", "APIKeyTypedDict": ".getrevenuecatkeysop", @@ -4446,6 +4846,8 @@ _dynamic_imports: dict[str, str] = { "ListCustomersInterval2": ".listcustomersop", "ListCustomersList": ".listcustomersop", "ListCustomersListTypedDict": ".listcustomersop", + "ListCustomersModelMarkups": ".listcustomersop", + "ListCustomersModelMarkupsTypedDict": ".listcustomersop", "ListCustomersOverageAllowed": ".listcustomersop", "ListCustomersOverageAllowedTypedDict": ".listcustomersop", "ListCustomersParams": ".listcustomersop", @@ -4455,6 +4857,8 @@ _dynamic_imports: dict[str, str] = { "ListCustomersProcessor": ".listcustomersop", "ListCustomersProcessors": ".listcustomersop", "ListCustomersProcessorsTypedDict": ".listcustomersop", + "ListCustomersProviderMarkups": ".listcustomersop", + "ListCustomersProviderMarkupsTypedDict": ".listcustomersop", "ListCustomersPurchase": ".listcustomersop", "ListCustomersPurchaseLimit1": ".listcustomersop", "ListCustomersPurchaseLimit1TypedDict": ".listcustomersop", @@ -4500,6 +4904,8 @@ _dynamic_imports: dict[str, str] = { "ListEntitiesInvoiceTypedDict": ".listentitiesop", "ListEntitiesList": ".listentitiesop", "ListEntitiesListTypedDict": ".listentitiesop", + "ListEntitiesModelMarkups": ".listentitiesop", + "ListEntitiesModelMarkupsTypedDict": ".listentitiesop", "ListEntitiesOverageAllowed": ".listentitiesop", "ListEntitiesOverageAllowedTypedDict": ".listentitiesop", "ListEntitiesParams": ".listentitiesop", @@ -4508,6 +4914,8 @@ _dynamic_imports: dict[str, str] = { "ListEntitiesPlanTypedDict": ".listentitiesop", "ListEntitiesProcessor": ".listentitiesop", "ListEntitiesProcessorType": ".listentitiesop", + "ListEntitiesProviderMarkups": ".listentitiesop", + "ListEntitiesProviderMarkupsTypedDict": ".listentitiesop", "ListEntitiesPurchase": ".listentitiesop", "ListEntitiesPurchaseScope": ".listentitiesop", "ListEntitiesPurchaseTypedDict": ".listentitiesop", @@ -4551,6 +4959,10 @@ _dynamic_imports: dict[str, str] = { "ListFeaturesGlobalsTypedDict": ".listfeaturesop", "ListFeaturesList": ".listfeaturesop", "ListFeaturesListTypedDict": ".listfeaturesop", + "ListFeaturesModelMarkups": ".listfeaturesop", + "ListFeaturesModelMarkupsTypedDict": ".listfeaturesop", + "ListFeaturesProviderMarkups": ".listfeaturesop", + "ListFeaturesProviderMarkupsTypedDict": ".listfeaturesop", "ListFeaturesRequest": ".listfeaturesop", "ListFeaturesRequestTypedDict": ".listfeaturesop", "ListFeaturesResponse": ".listfeaturesop", @@ -4600,7 +5012,11 @@ _dynamic_imports: dict[str, str] = { "ListPlansRollover": ".listplansop", "ListPlansRolloverTypedDict": ".listplansop", "ListPlansStatus": ".listplansop", + "ListPlansTier": ".listplansop", "ListPlansTierBehavior": ".listplansop", + "ListPlansTierTypedDict": ".listplansop", + "ListPlansTo": ".listplansop", + "ListPlansToTypedDict": ".listplansop", "ListPlansType": ".listplansop", "MultiAttachAttachDiscount": ".multiattachop", "MultiAttachAttachDiscountTypedDict": ".multiattachop", @@ -4706,7 +5122,11 @@ _dynamic_imports: dict[str, str] = { "PlanRollover": ".plan", "PlanRolloverTypedDict": ".plan", "PlanStatus": ".plan", + "PlanTier": ".plan", "PlanTierBehavior": ".plan", + "PlanTierTypedDict": ".plan", + "PlanTo": ".plan", + "PlanToTypedDict": ".plan", "PlanType": ".plan", "PlanTypedDict": ".plan", "PreviewAttachAddItemBillingMethod": ".previewattachop", @@ -4756,6 +5176,10 @@ _dynamic_imports: dict[str, str] = { "PreviewAttachIncomingFeatureQuantity": ".previewattachop", "PreviewAttachIncomingFeatureQuantityTypedDict": ".previewattachop", "PreviewAttachIncomingTypedDict": ".previewattachop", + "PreviewAttachIntervalRemoveItemEnum1": ".previewattachop", + "PreviewAttachIntervalRemoveItemEnum2": ".previewattachop", + "PreviewAttachIntervalUnion": ".previewattachop", + "PreviewAttachIntervalUnionTypedDict": ".previewattachop", "PreviewAttachInvoiceCredits": ".previewattachop", "PreviewAttachInvoiceCreditsTypedDict": ".previewattachop", "PreviewAttachInvoiceMode": ".previewattachop", @@ -4807,7 +5231,6 @@ _dynamic_imports: dict[str, str] = { "PreviewAttachProrationBehavior": ".previewattachop", "PreviewAttachRedirectMode": ".previewattachop", "PreviewAttachRemoveItemBillingMethod": ".previewattachop", - "PreviewAttachRemoveItemInterval": ".previewattachop", "PreviewAttachResponse": ".previewattachop", "PreviewAttachResponseTypedDict": ".previewattachop", "PreviewAttachStatus": ".previewattachop", @@ -4947,6 +5370,10 @@ _dynamic_imports: dict[str, str] = { "PreviewUpdateIncomingFeatureQuantity": ".previewupdateop", "PreviewUpdateIncomingFeatureQuantityTypedDict": ".previewupdateop", "PreviewUpdateIncomingTypedDict": ".previewupdateop", + "PreviewUpdateIntervalRemoveItemEnum1": ".previewupdateop", + "PreviewUpdateIntervalRemoveItemEnum2": ".previewupdateop", + "PreviewUpdateIntervalUnion": ".previewupdateop", + "PreviewUpdateIntervalUnionTypedDict": ".previewupdateop", "PreviewUpdateInvoiceCredits": ".previewupdateop", "PreviewUpdateInvoiceCreditsTypedDict": ".previewupdateop", "PreviewUpdateInvoiceMode": ".previewupdateop", @@ -4999,7 +5426,6 @@ _dynamic_imports: dict[str, str] = { "PreviewUpdateRecalculateBalancesTypedDict": ".previewupdateop", "PreviewUpdateRedirectMode": ".previewupdateop", "PreviewUpdateRemoveItemBillingMethod": ".previewupdateop", - "PreviewUpdateRemoveItemInterval": ".previewupdateop", "PreviewUpdateResponse": ".previewupdateop", "PreviewUpdateResponseTypedDict": ".previewupdateop", "PreviewUpdateStatus": ".previewupdateop", @@ -5065,6 +5491,10 @@ _dynamic_imports: dict[str, str] = { "SetupPaymentFreeTrialParamsTypedDict": ".setuppaymentop", "SetupPaymentGlobals": ".setuppaymentop", "SetupPaymentGlobalsTypedDict": ".setuppaymentop", + "SetupPaymentIntervalRemoveItemEnum1": ".setuppaymentop", + "SetupPaymentIntervalRemoveItemEnum2": ".setuppaymentop", + "SetupPaymentIntervalUnion": ".setuppaymentop", + "SetupPaymentIntervalUnionTypedDict": ".setuppaymentop", "SetupPaymentItemBillingMethod": ".setuppaymentop", "SetupPaymentItemExpiryDurationType": ".setuppaymentop", "SetupPaymentItemOnDecrease": ".setuppaymentop", @@ -5094,7 +5524,6 @@ _dynamic_imports: dict[str, str] = { "SetupPaymentPriceInterval": ".setuppaymentop", "SetupPaymentProrationBehavior": ".setuppaymentop", "SetupPaymentRemoveItemBillingMethod": ".setuppaymentop", - "SetupPaymentRemoveItemInterval": ".setuppaymentop", "SetupPaymentResponse": ".setuppaymentop", "SetupPaymentResponseTypedDict": ".setuppaymentop", "Result": ".syncrevenuecatop", @@ -5112,10 +5541,10 @@ _dynamic_imports: dict[str, str] = { "SyncRevenueCatResponse": ".syncrevenuecatop", "SyncRevenueCatResponseTypedDict": ".syncrevenuecatop", "SyncRevenueCatStatus": ".syncrevenuecatop", - "Deduction1": ".trackop", - "Deduction1TypedDict": ".trackop", - "Deduction2": ".trackop", - "Deduction2TypedDict": ".trackop", + "TrackDeduction1": ".trackop", + "TrackDeduction1TypedDict": ".trackop", + "TrackDeduction2": ".trackop", + "TrackDeduction2TypedDict": ".trackop", "TrackGlobals": ".trackop", "TrackGlobalsTypedDict": ".trackop", "TrackIntervalEnum1": ".trackop", @@ -5138,6 +5567,30 @@ _dynamic_imports: dict[str, str] = { "TrackResponseBody2": ".trackop", "TrackResponseBody2TypedDict": ".trackop", "TrackResponseTypedDict": ".trackop", + "TrackTokensDeduction1": ".tracktokensop", + "TrackTokensDeduction1TypedDict": ".tracktokensop", + "TrackTokensDeduction2": ".tracktokensop", + "TrackTokensDeduction2TypedDict": ".tracktokensop", + "TrackTokensGlobals": ".tracktokensop", + "TrackTokensGlobalsTypedDict": ".tracktokensop", + "TrackTokensIntervalEnum1": ".tracktokensop", + "TrackTokensIntervalEnum2": ".tracktokensop", + "TrackTokensIntervalUnion1": ".tracktokensop", + "TrackTokensIntervalUnion1TypedDict": ".tracktokensop", + "TrackTokensIntervalUnion2": ".tracktokensop", + "TrackTokensIntervalUnion2TypedDict": ".tracktokensop", + "TrackTokensParams": ".tracktokensop", + "TrackTokensParamsTypedDict": ".tracktokensop", + "TrackTokensReset1": ".tracktokensop", + "TrackTokensReset1TypedDict": ".tracktokensop", + "TrackTokensReset2": ".tracktokensop", + "TrackTokensReset2TypedDict": ".tracktokensop", + "TrackTokensResponse": ".tracktokensop", + "TrackTokensResponseBody1": ".tracktokensop", + "TrackTokensResponseBody1TypedDict": ".tracktokensop", + "TrackTokensResponseBody2": ".tracktokensop", + "TrackTokensResponseBody2TypedDict": ".tracktokensop", + "TrackTokensResponseTypedDict": ".tracktokensop", "UpdateBalanceGlobals": ".updatebalanceop", "UpdateBalanceGlobalsTypedDict": ".updatebalanceop", "UpdateBalanceInterval": ".updatebalanceop", @@ -5168,9 +5621,11 @@ _dynamic_imports: dict[str, str] = { "UpdateCustomerFlagsTypedDict": ".updatecustomerop", "UpdateCustomerGlobals": ".updatecustomerop", "UpdateCustomerGlobalsTypedDict": ".updatecustomerop", - "UpdateCustomerIntervalRequest": ".updatecustomerop", + "UpdateCustomerIntervalRequestBody": ".updatecustomerop", "UpdateCustomerIntervalResponse1": ".updatecustomerop", "UpdateCustomerIntervalResponse2": ".updatecustomerop", + "UpdateCustomerModelMarkups": ".updatecustomerop", + "UpdateCustomerModelMarkupsTypedDict": ".updatecustomerop", "UpdateCustomerOverageAllowedRequest": ".updatecustomerop", "UpdateCustomerOverageAllowedRequestTypedDict": ".updatecustomerop", "UpdateCustomerOverageAllowedResponse": ".updatecustomerop", @@ -5179,6 +5634,8 @@ _dynamic_imports: dict[str, str] = { "UpdateCustomerParamsTypedDict": ".updatecustomerop", "UpdateCustomerProcessors": ".updatecustomerop", "UpdateCustomerProcessorsTypedDict": ".updatecustomerop", + "UpdateCustomerProviderMarkups": ".updatecustomerop", + "UpdateCustomerProviderMarkupsTypedDict": ".updatecustomerop", "UpdateCustomerPurchase": ".updatecustomerop", "UpdateCustomerPurchaseLimitRequest": ".updatecustomerop", "UpdateCustomerPurchaseLimitRequestTypedDict": ".updatecustomerop", @@ -5230,6 +5687,8 @@ _dynamic_imports: dict[str, str] = { "UpdateEntityGlobalsTypedDict": ".updateentityop", "UpdateEntityInvoice": ".updateentityop", "UpdateEntityInvoiceTypedDict": ".updateentityop", + "UpdateEntityModelMarkups": ".updateentityop", + "UpdateEntityModelMarkupsTypedDict": ".updateentityop", "UpdateEntityOverageAllowedRequest": ".updateentityop", "UpdateEntityOverageAllowedRequestTypedDict": ".updateentityop", "UpdateEntityOverageAllowedResponse": ".updateentityop", @@ -5237,6 +5696,8 @@ _dynamic_imports: dict[str, str] = { "UpdateEntityParams": ".updateentityop", "UpdateEntityParamsTypedDict": ".updateentityop", "UpdateEntityProcessorType": ".updateentityop", + "UpdateEntityProviderMarkups": ".updateentityop", + "UpdateEntityProviderMarkupsTypedDict": ".updateentityop", "UpdateEntityPurchase": ".updateentityop", "UpdateEntityPurchaseScope": ".updateentityop", "UpdateEntityPurchaseTypedDict": ".updateentityop", @@ -5257,26 +5718,34 @@ _dynamic_imports: dict[str, str] = { "UpdateEntityUsageAlertRequestBodyTypedDict": ".updateentityop", "UpdateEntityUsageAlertResponse": ".updateentityop", "UpdateEntityUsageAlertResponseTypedDict": ".updateentityop", - "UpdateFeatureCreditSchemaRequest": ".updatefeatureop", - "UpdateFeatureCreditSchemaRequestTypedDict": ".updatefeatureop", + "UpdateFeatureCreditSchemaRequestBody": ".updatefeatureop", + "UpdateFeatureCreditSchemaRequestBodyTypedDict": ".updatefeatureop", "UpdateFeatureCreditSchemaResponse": ".updatefeatureop", "UpdateFeatureCreditSchemaResponseTypedDict": ".updatefeatureop", - "UpdateFeatureDisplayRequest": ".updatefeatureop", - "UpdateFeatureDisplayRequestTypedDict": ".updatefeatureop", + "UpdateFeatureDisplayRequestBody": ".updatefeatureop", + "UpdateFeatureDisplayRequestBodyTypedDict": ".updatefeatureop", "UpdateFeatureDisplayResponse": ".updatefeatureop", "UpdateFeatureDisplayResponseTypedDict": ".updatefeatureop", "UpdateFeatureGlobals": ".updatefeatureop", "UpdateFeatureGlobalsTypedDict": ".updatefeatureop", + "UpdateFeatureModelMarkupsRequest": ".updatefeatureop", + "UpdateFeatureModelMarkupsRequestTypedDict": ".updatefeatureop", + "UpdateFeatureModelMarkupsResponse": ".updatefeatureop", + "UpdateFeatureModelMarkupsResponseTypedDict": ".updatefeatureop", "UpdateFeatureParams": ".updatefeatureop", "UpdateFeatureParamsTypedDict": ".updatefeatureop", + "UpdateFeatureProviderMarkupsRequest": ".updatefeatureop", + "UpdateFeatureProviderMarkupsRequestTypedDict": ".updatefeatureop", + "UpdateFeatureProviderMarkupsResponse": ".updatefeatureop", + "UpdateFeatureProviderMarkupsResponseTypedDict": ".updatefeatureop", "UpdateFeatureResponse": ".updatefeatureop", "UpdateFeatureResponseTypedDict": ".updatefeatureop", - "UpdateFeatureTypeRequest": ".updatefeatureop", + "UpdateFeatureTypeRequestBody": ".updatefeatureop", "UpdateFeatureTypeResponse": ".updatefeatureop", "UpdatePlanAttachAction": ".updateplanop", "UpdatePlanBasePrice": ".updateplanop", "UpdatePlanBasePriceTypedDict": ".updateplanop", - "UpdatePlanBillingMethodRequest": ".updateplanop", + "UpdatePlanBillingMethodRequestBody": ".updateplanop", "UpdatePlanBillingMethodResponse": ".updateplanop", "UpdatePlanConfigRequest": ".updateplanop", "UpdatePlanConfigRequestTypedDict": ".updateplanop", @@ -5289,7 +5758,7 @@ _dynamic_imports: dict[str, str] = { "UpdatePlanDurationTypeRequest": ".updateplanop", "UpdatePlanDurationTypeResponse": ".updateplanop", "UpdatePlanEnv": ".updateplanop", - "UpdatePlanExpiryDurationTypeRequest": ".updateplanop", + "UpdatePlanExpiryDurationTypeRequestBody": ".updateplanop", "UpdatePlanExpiryDurationTypeResponse": ".updateplanop", "UpdatePlanFeature": ".updateplanop", "UpdatePlanFeatureDisplay": ".updateplanop", @@ -5304,7 +5773,7 @@ _dynamic_imports: dict[str, str] = { "UpdatePlanItem": ".updateplanop", "UpdatePlanItemDisplay": ".updateplanop", "UpdatePlanItemDisplayTypedDict": ".updateplanop", - "UpdatePlanItemPriceIntervalRequest": ".updateplanop", + "UpdatePlanItemPriceIntervalRequestBody": ".updateplanop", "UpdatePlanItemPriceResponse": ".updateplanop", "UpdatePlanItemPriceResponseTypedDict": ".updateplanop", "UpdatePlanItemTypedDict": ".updateplanop", @@ -5318,34 +5787,38 @@ _dynamic_imports: dict[str, str] = { "UpdatePlanPlanItemTypedDict": ".updateplanop", "UpdatePlanPriceDisplay": ".updateplanop", "UpdatePlanPriceDisplayTypedDict": ".updateplanop", - "UpdatePlanPriceIntervalRequest": ".updateplanop", + "UpdatePlanPriceIntervalRequestBody": ".updateplanop", "UpdatePlanPriceIntervalResponse": ".updateplanop", "UpdatePlanPriceItemIntervalResponse": ".updateplanop", - "UpdatePlanPriceRequest": ".updateplanop", - "UpdatePlanPriceRequestTypedDict": ".updateplanop", + "UpdatePlanPriceRequestBody": ".updateplanop", + "UpdatePlanPriceRequestBodyTypedDict": ".updateplanop", "UpdatePlanPriceResponse": ".updateplanop", "UpdatePlanPriceResponseTypedDict": ".updateplanop", "UpdatePlanProration": ".updateplanop", "UpdatePlanProrationTypedDict": ".updateplanop", - "UpdatePlanResetIntervalRequest": ".updateplanop", + "UpdatePlanResetIntervalRequestBody": ".updateplanop", "UpdatePlanResetIntervalResponse": ".updateplanop", - "UpdatePlanResetRequest": ".updateplanop", - "UpdatePlanResetRequestTypedDict": ".updateplanop", + "UpdatePlanResetRequestBody": ".updateplanop", + "UpdatePlanResetRequestBodyTypedDict": ".updateplanop", "UpdatePlanResetResponse": ".updateplanop", "UpdatePlanResetResponseTypedDict": ".updateplanop", "UpdatePlanResponse": ".updateplanop", "UpdatePlanResponseTypedDict": ".updateplanop", - "UpdatePlanRolloverRequest": ".updateplanop", - "UpdatePlanRolloverRequestTypedDict": ".updateplanop", + "UpdatePlanRolloverRequestBody": ".updateplanop", + "UpdatePlanRolloverRequestBodyTypedDict": ".updateplanop", "UpdatePlanRolloverResponse": ".updateplanop", "UpdatePlanRolloverResponseTypedDict": ".updateplanop", "UpdatePlanStatus": ".updateplanop", - "UpdatePlanTier": ".updateplanop", - "UpdatePlanTierBehaviorRequest": ".updateplanop", + "UpdatePlanTierBehaviorRequestBody": ".updateplanop", "UpdatePlanTierBehaviorResponse": ".updateplanop", - "UpdatePlanTierTypedDict": ".updateplanop", - "UpdatePlanTo": ".updateplanop", - "UpdatePlanToTypedDict": ".updateplanop", + "UpdatePlanTierRequestBody": ".updateplanop", + "UpdatePlanTierRequestBodyTypedDict": ".updateplanop", + "UpdatePlanTierResponse": ".updateplanop", + "UpdatePlanTierResponseTypedDict": ".updateplanop", + "UpdatePlanToRequestBody": ".updateplanop", + "UpdatePlanToRequestBodyTypedDict": ".updateplanop", + "UpdatePlanToResponse": ".updateplanop", + "UpdatePlanToResponseTypedDict": ".updateplanop", "UpdatePlanType": ".updateplanop", } diff --git a/others/python-sdk/src/autumn_sdk/models/attachop.py b/others/python-sdk/src/autumn_sdk/models/attachop.py index 5d591a0d1..8f0e48bd4 100644 --- a/others/python-sdk/src/autumn_sdk/models/attachop.py +++ b/others/python-sdk/src/autumn_sdk/models/attachop.py @@ -261,8 +261,8 @@ class AttachItemPriceTypedDict(TypedDict): r"""Number of intervals per billing cycle. Defaults to 1.""" billing_units: NotRequired[float] r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: NotRequired[float] - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" class AttachItemPrice(BaseModel): @@ -288,8 +288,8 @@ class AttachItemPrice(BaseModel): billing_units: Optional[float] = 1 r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: Optional[float] = None - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" @model_serializer(mode="wrap") def serialize_model(self, handler): @@ -303,15 +303,24 @@ class AttachItemPrice(BaseModel): "max_purchase", ] ) + nullable_fields = set(["max_purchase"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m @@ -594,8 +603,8 @@ class AttachAddItemPriceTypedDict(TypedDict): r"""Number of intervals per billing cycle. Defaults to 1.""" billing_units: NotRequired[float] r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: NotRequired[float] - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" class AttachAddItemPrice(BaseModel): @@ -621,8 +630,8 @@ class AttachAddItemPrice(BaseModel): billing_units: Optional[float] = 1 r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: Optional[float] = None - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" @model_serializer(mode="wrap") def serialize_model(self, handler): @@ -636,15 +645,24 @@ class AttachAddItemPrice(BaseModel): "max_purchase", ] ) + nullable_fields = set(["max_purchase"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m @@ -809,7 +827,20 @@ AttachRemoveItemBillingMethod = Literal[ r"""Match items with this billing method (prepaid or usage_based).""" -AttachRemoveItemInterval = Literal[ +AttachIntervalRemoveItemEnum2 = Literal[ + "one_off", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "semi_annual", + "year", +] + + +AttachIntervalRemoveItemEnum1 = Literal[ "one_off", "week", "month", @@ -817,7 +848,20 @@ AttachRemoveItemInterval = Literal[ "semi_annual", "year", ] -r"""Match items with this interval.""" + + +AttachIntervalUnionTypedDict = TypeAliasType( + "AttachIntervalUnionTypedDict", + Union[AttachIntervalRemoveItemEnum1, AttachIntervalRemoveItemEnum2], +) +r"""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.""" + + +AttachIntervalUnion = TypeAliasType( + "AttachIntervalUnion", + Union[AttachIntervalRemoveItemEnum1, AttachIntervalRemoveItemEnum2], +) +r"""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.""" class AttachPlanItemFilterTypedDict(TypedDict): @@ -827,8 +871,10 @@ class AttachPlanItemFilterTypedDict(TypedDict): r"""Match items linked to this feature.""" billing_method: NotRequired[AttachRemoveItemBillingMethod] r"""Match items with this billing method (prepaid or usage_based).""" - interval: NotRequired[AttachRemoveItemInterval] - r"""Match items with this interval.""" + interval: NotRequired[AttachIntervalUnionTypedDict] + r"""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: NotRequired[int] + r"""Match items with this interval_count. Disambiguates between items that share an interval but differ in count.""" class AttachPlanItemFilter(BaseModel): @@ -840,12 +886,17 @@ class AttachPlanItemFilter(BaseModel): billing_method: Optional[AttachRemoveItemBillingMethod] = None r"""Match items with this billing method (prepaid or usage_based).""" - interval: Optional[AttachRemoveItemInterval] = None - r"""Match items with this interval.""" + interval: Optional[AttachIntervalUnion] = None + r"""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: Optional[int] = None + r"""Match items with this interval_count. Disambiguates between items that share an interval but differ in count.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["feature_id", "billing_method", "interval"]) + optional_fields = set( + ["feature_id", "billing_method", "interval", "interval_count"] + ) serialized = handler(self) m = {} @@ -926,7 +977,7 @@ class AttachCustomizeTypedDict(TypedDict): price: NotRequired[Nullable[AttachBasePriceTypedDict]] r"""Override the base price of the plan. Pass null to remove the base price.""" items: NotRequired[List[AttachItemPlanItemTypedDict]] - r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.""" + r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.""" add_items: NotRequired[List[AttachAddItemPlanItemTypedDict]] r"""Items to add to the plan.""" remove_items: NotRequired[List[AttachPlanItemFilterTypedDict]] @@ -942,7 +993,7 @@ class AttachCustomize(BaseModel): r"""Override the base price of the plan. Pass null to remove the base price.""" items: Optional[List[AttachItemPlanItem]] = None - r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.""" + r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.""" add_items: Optional[List[AttachAddItemPlanItem]] = None r"""Items to add to the plan.""" diff --git a/others/python-sdk/src/autumn_sdk/models/balance.py b/others/python-sdk/src/autumn_sdk/models/balance.py index 10a5b2ae1..0b7d37dc5 100644 --- a/others/python-sdk/src/autumn_sdk/models/balance.py +++ b/others/python-sdk/src/autumn_sdk/models/balance.py @@ -10,7 +10,7 @@ from autumn_sdk.types import ( UnrecognizedStr, ) from pydantic import model_serializer -from typing import Any, List, Literal, Optional, Union +from typing import Dict, List, Literal, Optional, Union from typing_extensions import NotRequired, TypeAliasType, TypedDict @@ -19,10 +19,11 @@ BalanceType = Union[ "boolean", "metered", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] -r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" +r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" class BalanceCreditSchemaTypedDict(TypedDict): @@ -40,6 +41,44 @@ class BalanceCreditSchema(BaseModel): r"""Credits consumed per unit of the metered feature.""" +class BalanceModelMarkupsTypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class BalanceModelMarkups(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 BalanceProviderMarkupsTypedDict(TypedDict): + markup: float + + +class BalanceProviderMarkups(BaseModel): + markup: float + + class BalanceDisplayTypedDict(TypedDict): r"""Display names for the feature in billing UI and customer-facing components.""" @@ -92,7 +131,7 @@ class BalanceFeatureTypedDict(TypedDict): name: str r"""Human-readable name displayed in the dashboard and billing UI.""" type: BalanceType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" archived: bool @@ -101,6 +140,12 @@ class BalanceFeatureTypedDict(TypedDict): r"""Event names that trigger this feature's balance. Allows multiple features to respond to a single event.""" credit_schema: NotRequired[List[BalanceCreditSchemaTypedDict]] r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: NotRequired[Nullable[Dict[str, BalanceModelMarkupsTypedDict]]] + r"""Per-model markup overrides for AI credit systems.""" + default_markup: NotRequired[float] + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + provider_markups: NotRequired[Nullable[Dict[str, BalanceProviderMarkupsTypedDict]]] + r"""Per-provider default markup percentages for AI credit systems.""" display: NotRequired[BalanceDisplayTypedDict] r"""Display names for the feature in billing UI and customer-facing components.""" @@ -115,7 +160,7 @@ class BalanceFeature(BaseModel): r"""Human-readable name displayed in the dashboard and billing UI.""" type: BalanceType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" @@ -129,21 +174,48 @@ class BalanceFeature(BaseModel): credit_schema: Optional[List[BalanceCreditSchema]] = None r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: OptionalNullable[Dict[str, BalanceModelMarkups]] = UNSET + r"""Per-model markup overrides for AI credit systems.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[Dict[str, BalanceProviderMarkups]] = UNSET + r"""Per-provider default markup percentages for AI credit systems.""" + display: Optional[BalanceDisplay] = None r"""Display names for the feature in billing UI and customer-facing components.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["event_names", "credit_schema", "display"]) + optional_fields = set( + [ + "event_names", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "display", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m @@ -222,6 +294,42 @@ class BalanceReset(BaseModel): return m +BalanceToTypedDict = TypeAliasType("BalanceToTypedDict", Union[float, str]) + + +BalanceTo = TypeAliasType("BalanceTo", Union[float, str]) + + +class BalanceTierTypedDict(TypedDict): + to: BalanceToTypedDict + amount: float + flat_amount: NotRequired[float] + + +class BalanceTier(BaseModel): + to: BalanceTo + + amount: float + + flat_amount: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["flat_amount"]) + 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 + + BalanceTierBehavior = Union[ Literal[ "graduated", @@ -251,7 +359,7 @@ class BalancePriceTypedDict(TypedDict): r"""Maximum quantity that can be purchased, or null for unlimited.""" amount: NotRequired[float] r"""The per-unit price amount.""" - tiers: NotRequired[List[Nullable[Any]]] + tiers: NotRequired[List[BalanceTierTypedDict]] r"""Tiered pricing configuration if applicable.""" tier_behavior: NotRequired[BalanceTierBehavior] r"""How tiers are applied: graduated (split across bands) or volume (flat rate for the matched tier).""" @@ -270,7 +378,7 @@ class BalancePrice(BaseModel): amount: Optional[float] = None r"""The per-unit price amount.""" - tiers: Optional[List[Nullable[Any]]] = None + tiers: Optional[List[BalanceTier]] = None r"""Tiered pricing configuration if applicable.""" tier_behavior: Optional[BalanceTierBehavior] = None diff --git a/others/python-sdk/src/autumn_sdk/models/billingupdateop.py b/others/python-sdk/src/autumn_sdk/models/billingupdateop.py index ac6c4e6ec..1b3cfbd4e 100644 --- a/others/python-sdk/src/autumn_sdk/models/billingupdateop.py +++ b/others/python-sdk/src/autumn_sdk/models/billingupdateop.py @@ -263,8 +263,8 @@ class BillingUpdateItemPriceTypedDict(TypedDict): r"""Number of intervals per billing cycle. Defaults to 1.""" billing_units: NotRequired[float] r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: NotRequired[float] - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" class BillingUpdateItemPrice(BaseModel): @@ -290,8 +290,8 @@ class BillingUpdateItemPrice(BaseModel): billing_units: Optional[float] = 1 r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: Optional[float] = None - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" @model_serializer(mode="wrap") def serialize_model(self, handler): @@ -305,15 +305,24 @@ class BillingUpdateItemPrice(BaseModel): "max_purchase", ] ) + nullable_fields = set(["max_purchase"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m @@ -598,8 +607,8 @@ class BillingUpdateAddItemPriceTypedDict(TypedDict): r"""Number of intervals per billing cycle. Defaults to 1.""" billing_units: NotRequired[float] r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: NotRequired[float] - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" class BillingUpdateAddItemPrice(BaseModel): @@ -625,8 +634,8 @@ class BillingUpdateAddItemPrice(BaseModel): billing_units: Optional[float] = 1 r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: Optional[float] = None - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" @model_serializer(mode="wrap") def serialize_model(self, handler): @@ -640,15 +649,24 @@ class BillingUpdateAddItemPrice(BaseModel): "max_purchase", ] ) + nullable_fields = set(["max_purchase"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m @@ -813,7 +831,20 @@ BillingUpdateRemoveItemBillingMethod = Literal[ r"""Match items with this billing method (prepaid or usage_based).""" -BillingUpdateRemoveItemInterval = Literal[ +BillingUpdateIntervalRemoveItemEnum2 = Literal[ + "one_off", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "semi_annual", + "year", +] + + +BillingUpdateIntervalRemoveItemEnum1 = Literal[ "one_off", "week", "month", @@ -821,7 +852,20 @@ BillingUpdateRemoveItemInterval = Literal[ "semi_annual", "year", ] -r"""Match items with this interval.""" + + +BillingUpdateIntervalUnionTypedDict = TypeAliasType( + "BillingUpdateIntervalUnionTypedDict", + Union[BillingUpdateIntervalRemoveItemEnum1, BillingUpdateIntervalRemoveItemEnum2], +) +r"""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.""" + + +BillingUpdateIntervalUnion = TypeAliasType( + "BillingUpdateIntervalUnion", + Union[BillingUpdateIntervalRemoveItemEnum1, BillingUpdateIntervalRemoveItemEnum2], +) +r"""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.""" class BillingUpdatePlanItemFilterTypedDict(TypedDict): @@ -831,8 +875,10 @@ class BillingUpdatePlanItemFilterTypedDict(TypedDict): r"""Match items linked to this feature.""" billing_method: NotRequired[BillingUpdateRemoveItemBillingMethod] r"""Match items with this billing method (prepaid or usage_based).""" - interval: NotRequired[BillingUpdateRemoveItemInterval] - r"""Match items with this interval.""" + interval: NotRequired[BillingUpdateIntervalUnionTypedDict] + r"""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: NotRequired[int] + r"""Match items with this interval_count. Disambiguates between items that share an interval but differ in count.""" class BillingUpdatePlanItemFilter(BaseModel): @@ -844,12 +890,17 @@ class BillingUpdatePlanItemFilter(BaseModel): billing_method: Optional[BillingUpdateRemoveItemBillingMethod] = None r"""Match items with this billing method (prepaid or usage_based).""" - interval: Optional[BillingUpdateRemoveItemInterval] = None - r"""Match items with this interval.""" + interval: Optional[BillingUpdateIntervalUnion] = None + r"""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: Optional[int] = None + r"""Match items with this interval_count. Disambiguates between items that share an interval but differ in count.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["feature_id", "billing_method", "interval"]) + optional_fields = set( + ["feature_id", "billing_method", "interval", "interval_count"] + ) serialized = handler(self) m = {} @@ -930,7 +981,7 @@ class BillingUpdateCustomizeTypedDict(TypedDict): price: NotRequired[Nullable[BillingUpdateBasePriceTypedDict]] r"""Override the base price of the plan. Pass null to remove the base price.""" items: NotRequired[List[BillingUpdateItemPlanItemTypedDict]] - r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.""" + r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.""" add_items: NotRequired[List[BillingUpdateAddItemPlanItemTypedDict]] r"""Items to add to the plan.""" remove_items: NotRequired[List[BillingUpdatePlanItemFilterTypedDict]] @@ -946,7 +997,7 @@ class BillingUpdateCustomize(BaseModel): r"""Override the base price of the plan. Pass null to remove the base price.""" items: Optional[List[BillingUpdateItemPlanItem]] = None - r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.""" + r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.""" add_items: Optional[List[BillingUpdateAddItemPlanItem]] = None r"""Items to add to the plan.""" diff --git a/others/python-sdk/src/autumn_sdk/models/checkop.py b/others/python-sdk/src/autumn_sdk/models/checkop.py index 6c98274a9..5ca4b0b88 100644 --- a/others/python-sdk/src/autumn_sdk/models/checkop.py +++ b/others/python-sdk/src/autumn_sdk/models/checkop.py @@ -164,10 +164,11 @@ FlagType2 = Union[ "boolean", "metered", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] -r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" +r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" class CheckCreditSchema2TypedDict(TypedDict): @@ -185,6 +186,44 @@ class CheckCreditSchema2(BaseModel): r"""Credits consumed per unit of the metered feature.""" +class CheckModelMarkups2TypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class CheckModelMarkups2(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 CheckProviderMarkups2TypedDict(TypedDict): + markup: float + + +class CheckProviderMarkups2(BaseModel): + markup: float + + class FlagDisplay2TypedDict(TypedDict): r"""Display names for the feature in billing UI and customer-facing components.""" @@ -237,7 +276,7 @@ class CheckFeature2TypedDict(TypedDict): name: str r"""Human-readable name displayed in the dashboard and billing UI.""" type: FlagType2 - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" archived: bool @@ -246,6 +285,12 @@ class CheckFeature2TypedDict(TypedDict): r"""Event names that trigger this feature's balance. Allows multiple features to respond to a single event.""" credit_schema: NotRequired[List[CheckCreditSchema2TypedDict]] r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: NotRequired[Nullable[Dict[str, CheckModelMarkups2TypedDict]]] + r"""Per-model markup overrides for AI credit systems.""" + default_markup: NotRequired[float] + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + provider_markups: NotRequired[Nullable[Dict[str, CheckProviderMarkups2TypedDict]]] + r"""Per-provider default markup percentages for AI credit systems.""" display: NotRequired[FlagDisplay2TypedDict] r"""Display names for the feature in billing UI and customer-facing components.""" @@ -260,7 +305,7 @@ class CheckFeature2(BaseModel): r"""Human-readable name displayed in the dashboard and billing UI.""" type: FlagType2 - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" @@ -274,21 +319,48 @@ class CheckFeature2(BaseModel): credit_schema: Optional[List[CheckCreditSchema2]] = None r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: OptionalNullable[Dict[str, CheckModelMarkups2]] = UNSET + r"""Per-model markup overrides for AI credit systems.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[Dict[str, CheckProviderMarkups2]] = UNSET + r"""Per-provider default markup percentages for AI credit systems.""" + display: Optional[FlagDisplay2] = None r"""Display names for the feature in billing UI and customer-facing components.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["event_names", "credit_schema", "display"]) + optional_fields = set( + [ + "event_names", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "display", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m @@ -1106,10 +1178,11 @@ FlagType1 = Union[ "boolean", "metered", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] -r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" +r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" class CheckCreditSchema1TypedDict(TypedDict): @@ -1127,6 +1200,44 @@ class CheckCreditSchema1(BaseModel): r"""Credits consumed per unit of the metered feature.""" +class CheckModelMarkups1TypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class CheckModelMarkups1(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 CheckProviderMarkups1TypedDict(TypedDict): + markup: float + + +class CheckProviderMarkups1(BaseModel): + markup: float + + class FlagDisplay1TypedDict(TypedDict): r"""Display names for the feature in billing UI and customer-facing components.""" @@ -1179,7 +1290,7 @@ class CheckFeature1TypedDict(TypedDict): name: str r"""Human-readable name displayed in the dashboard and billing UI.""" type: FlagType1 - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" archived: bool @@ -1188,6 +1299,12 @@ class CheckFeature1TypedDict(TypedDict): r"""Event names that trigger this feature's balance. Allows multiple features to respond to a single event.""" credit_schema: NotRequired[List[CheckCreditSchema1TypedDict]] r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: NotRequired[Nullable[Dict[str, CheckModelMarkups1TypedDict]]] + r"""Per-model markup overrides for AI credit systems.""" + default_markup: NotRequired[float] + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + provider_markups: NotRequired[Nullable[Dict[str, CheckProviderMarkups1TypedDict]]] + r"""Per-provider default markup percentages for AI credit systems.""" display: NotRequired[FlagDisplay1TypedDict] r"""Display names for the feature in billing UI and customer-facing components.""" @@ -1202,7 +1319,7 @@ class CheckFeature1(BaseModel): r"""Human-readable name displayed in the dashboard and billing UI.""" type: FlagType1 - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" @@ -1216,21 +1333,48 @@ class CheckFeature1(BaseModel): credit_schema: Optional[List[CheckCreditSchema1]] = None r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: OptionalNullable[Dict[str, CheckModelMarkups1]] = UNSET + r"""Per-model markup overrides for AI credit systems.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[Dict[str, CheckProviderMarkups1]] = UNSET + r"""Per-provider default markup percentages for AI credit systems.""" + display: Optional[FlagDisplay1] = None r"""Display names for the feature in billing UI and customer-facing components.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["event_names", "credit_schema", "display"]) + optional_fields = set( + [ + "event_names", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "display", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m diff --git a/others/python-sdk/src/autumn_sdk/models/createentityop.py b/others/python-sdk/src/autumn_sdk/models/createentityop.py index 1ad23c8de..87b3d95cc 100644 --- a/others/python-sdk/src/autumn_sdk/models/createentityop.py +++ b/others/python-sdk/src/autumn_sdk/models/createentityop.py @@ -483,10 +483,11 @@ CreateEntityType = Union[ "boolean", "metered", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] -r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" +r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" class CreateEntityCreditSchemaTypedDict(TypedDict): @@ -504,6 +505,44 @@ class CreateEntityCreditSchema(BaseModel): r"""Credits consumed per unit of the metered feature.""" +class CreateEntityModelMarkupsTypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class CreateEntityModelMarkups(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 CreateEntityProviderMarkupsTypedDict(TypedDict): + markup: float + + +class CreateEntityProviderMarkups(BaseModel): + markup: float + + class CreateEntityDisplayTypedDict(TypedDict): r"""Display names for the feature in billing UI and customer-facing components.""" @@ -556,7 +595,7 @@ class CreateEntityFeatureTypedDict(TypedDict): name: str r"""Human-readable name displayed in the dashboard and billing UI.""" type: CreateEntityType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" archived: bool @@ -565,6 +604,14 @@ class CreateEntityFeatureTypedDict(TypedDict): r"""Event names that trigger this feature's balance. Allows multiple features to respond to a single event.""" credit_schema: NotRequired[List[CreateEntityCreditSchemaTypedDict]] r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: NotRequired[Nullable[Dict[str, CreateEntityModelMarkupsTypedDict]]] + r"""Per-model markup overrides for AI credit systems.""" + default_markup: NotRequired[float] + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + provider_markups: NotRequired[ + Nullable[Dict[str, CreateEntityProviderMarkupsTypedDict]] + ] + r"""Per-provider default markup percentages for AI credit systems.""" display: NotRequired[CreateEntityDisplayTypedDict] r"""Display names for the feature in billing UI and customer-facing components.""" @@ -579,7 +626,7 @@ class CreateEntityFeature(BaseModel): r"""Human-readable name displayed in the dashboard and billing UI.""" type: CreateEntityType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" @@ -593,21 +640,48 @@ class CreateEntityFeature(BaseModel): credit_schema: Optional[List[CreateEntityCreditSchema]] = None r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: OptionalNullable[Dict[str, CreateEntityModelMarkups]] = UNSET + r"""Per-model markup overrides for AI credit systems.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[Dict[str, CreateEntityProviderMarkups]] = UNSET + r"""Per-provider default markup percentages for AI credit systems.""" + display: Optional[CreateEntityDisplay] = None r"""Display names for the feature in billing UI and customer-facing components.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["event_names", "credit_schema", "display"]) + optional_fields = set( + [ + "event_names", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "display", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m diff --git a/others/python-sdk/src/autumn_sdk/models/createfeatureop.py b/others/python-sdk/src/autumn_sdk/models/createfeatureop.py index 3a3fa7e5c..496a07693 100644 --- a/others/python-sdk/src/autumn_sdk/models/createfeatureop.py +++ b/others/python-sdk/src/autumn_sdk/models/createfeatureop.py @@ -12,7 +12,7 @@ from autumn_sdk.types import ( from autumn_sdk.utils import FieldMetadata, HeaderMetadata import pydantic from pydantic import model_serializer -from typing import List, Literal, Optional, Union +from typing import Dict, List, Literal, Optional, Union from typing_extensions import Annotated, NotRequired, TypedDict @@ -44,22 +44,23 @@ class CreateFeatureGlobals(BaseModel): return m -CreateFeatureTypeRequest = Literal[ +CreateFeatureTypeRequestBody = Literal[ "boolean", "metered", "credit_system", + "ai_credit_system", ] r"""The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system.""" -class CreateFeatureDisplayRequestTypedDict(TypedDict): +class CreateFeatureDisplayRequestBodyTypedDict(TypedDict): r"""Singular and plural display names for the feature in your user interface.""" singular: str plural: str -class CreateFeatureDisplayRequest(BaseModel): +class CreateFeatureDisplayRequestBody(BaseModel): r"""Singular and plural display names for the feature in your user interface.""" singular: str @@ -67,57 +68,33 @@ class CreateFeatureDisplayRequest(BaseModel): plural: str -class CreateFeatureCreditSchemaRequestTypedDict(TypedDict): +class CreateFeatureCreditSchemaRequestBodyTypedDict(TypedDict): metered_feature_id: str credit_cost: float -class CreateFeatureCreditSchemaRequest(BaseModel): +class CreateFeatureCreditSchemaRequestBody(BaseModel): metered_feature_id: str credit_cost: float -class CreateFeatureParamsTypedDict(TypedDict): - name: str - r"""The name of the feature.""" - type: CreateFeatureTypeRequest - r"""The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system.""" - feature_id: str - r"""The ID of the feature to create.""" - consumable: NotRequired[bool] - r"""Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features.""" - display: NotRequired[CreateFeatureDisplayRequestTypedDict] - r"""Singular and plural display names for the feature in your user interface.""" - credit_schema: NotRequired[List[CreateFeatureCreditSchemaRequestTypedDict]] - r"""A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features.""" - event_names: NotRequired[List[str]] +class CreateFeatureModelMarkupsRequestTypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] -class CreateFeatureParams(BaseModel): - name: str - r"""The name of the feature.""" +class CreateFeatureModelMarkupsRequest(BaseModel): + markup: Optional[float] = None - type: CreateFeatureTypeRequest - r"""The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system.""" + input_cost: Optional[float] = None - feature_id: str - r"""The ID of the feature to create.""" - - consumable: Optional[bool] = None - r"""Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features.""" - - display: Optional[CreateFeatureDisplayRequest] = None - r"""Singular and plural display names for the feature in your user interface.""" - - credit_schema: Optional[List[CreateFeatureCreditSchemaRequest]] = None - r"""A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features.""" - - event_names: Optional[List[str]] = None + output_cost: Optional[float] = None @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["consumable", "display", "credit_schema", "event_names"]) + optional_fields = set(["markup", "input_cost", "output_cost"]) serialized = handler(self) m = {} @@ -132,15 +109,118 @@ class CreateFeatureParams(BaseModel): return m +class CreateFeatureProviderMarkupsRequestTypedDict(TypedDict): + markup: float + + +class CreateFeatureProviderMarkupsRequest(BaseModel): + markup: float + + +class CreateFeatureParamsTypedDict(TypedDict): + name: str + r"""The name of the feature.""" + type: CreateFeatureTypeRequestBody + r"""The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system.""" + feature_id: str + r"""The ID of the feature to create.""" + consumable: NotRequired[bool] + r"""Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features.""" + display: NotRequired[CreateFeatureDisplayRequestBodyTypedDict] + r"""Singular and plural display names for the feature in your user interface.""" + credit_schema: NotRequired[List[CreateFeatureCreditSchemaRequestBodyTypedDict]] + r"""A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead.""" + model_markups: NotRequired[ + Nullable[Dict[str, CreateFeatureModelMarkupsRequestTypedDict]] + ] + r"""Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration.""" + default_markup: NotRequired[float] + r"""Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free.""" + provider_markups: NotRequired[ + Nullable[Dict[str, CreateFeatureProviderMarkupsRequestTypedDict]] + ] + r"""Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id.""" + event_names: NotRequired[List[str]] + + +class CreateFeatureParams(BaseModel): + name: str + r"""The name of the feature.""" + + type: CreateFeatureTypeRequestBody + r"""The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system.""" + + feature_id: str + r"""The ID of the feature to create.""" + + consumable: Optional[bool] = None + r"""Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features.""" + + display: Optional[CreateFeatureDisplayRequestBody] = None + r"""Singular and plural display names for the feature in your user interface.""" + + credit_schema: Optional[List[CreateFeatureCreditSchemaRequestBody]] = None + r"""A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead.""" + + model_markups: OptionalNullable[Dict[str, CreateFeatureModelMarkupsRequest]] = UNSET + r"""Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[ + Dict[str, CreateFeatureProviderMarkupsRequest] + ] = UNSET + r"""Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id.""" + + event_names: Optional[List[str]] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set( + [ + "consumable", + "display", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "event_names", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) + 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)) + 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 + + return m + + CreateFeatureTypeResponse = Union[ Literal[ "boolean", "metered", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] -r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" +r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" class CreateFeatureCreditSchemaResponseTypedDict(TypedDict): @@ -158,6 +238,44 @@ class CreateFeatureCreditSchemaResponse(BaseModel): r"""Credits consumed per unit of the metered feature.""" +class CreateFeatureModelMarkupsResponseTypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class CreateFeatureModelMarkupsResponse(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 CreateFeatureProviderMarkupsResponseTypedDict(TypedDict): + markup: float + + +class CreateFeatureProviderMarkupsResponse(BaseModel): + markup: float + + class CreateFeatureDisplayResponseTypedDict(TypedDict): r"""Display names for the feature in billing UI and customer-facing components.""" @@ -210,7 +328,7 @@ class CreateFeatureResponseTypedDict(TypedDict): name: str r"""Human-readable name displayed in the dashboard and billing UI.""" type: CreateFeatureTypeResponse - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" archived: bool @@ -219,6 +337,16 @@ class CreateFeatureResponseTypedDict(TypedDict): r"""Event names that trigger this feature's balance. Allows multiple features to respond to a single event.""" credit_schema: NotRequired[List[CreateFeatureCreditSchemaResponseTypedDict]] r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: NotRequired[ + Nullable[Dict[str, CreateFeatureModelMarkupsResponseTypedDict]] + ] + r"""Per-model markup overrides for AI credit systems.""" + default_markup: NotRequired[float] + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + provider_markups: NotRequired[ + Nullable[Dict[str, CreateFeatureProviderMarkupsResponseTypedDict]] + ] + r"""Per-provider default markup percentages for AI credit systems.""" display: NotRequired[CreateFeatureDisplayResponseTypedDict] r"""Display names for the feature in billing UI and customer-facing components.""" @@ -233,7 +361,7 @@ class CreateFeatureResponse(BaseModel): r"""Human-readable name displayed in the dashboard and billing UI.""" type: CreateFeatureTypeResponse - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" @@ -247,21 +375,52 @@ class CreateFeatureResponse(BaseModel): credit_schema: Optional[List[CreateFeatureCreditSchemaResponse]] = None r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: OptionalNullable[Dict[str, CreateFeatureModelMarkupsResponse]] = ( + UNSET + ) + r"""Per-model markup overrides for AI credit systems.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[ + Dict[str, CreateFeatureProviderMarkupsResponse] + ] = UNSET + r"""Per-provider default markup percentages for AI credit systems.""" + display: Optional[CreateFeatureDisplayResponse] = None r"""Display names for the feature in billing UI and customer-facing components.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["event_names", "credit_schema", "display"]) + optional_fields = set( + [ + "event_names", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "display", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m diff --git a/others/python-sdk/src/autumn_sdk/models/createplanop.py b/others/python-sdk/src/autumn_sdk/models/createplanop.py index 1b6499a88..7496b2429 100644 --- a/others/python-sdk/src/autumn_sdk/models/createplanop.py +++ b/others/python-sdk/src/autumn_sdk/models/createplanop.py @@ -12,7 +12,7 @@ from autumn_sdk.types import ( from autumn_sdk.utils import FieldMetadata, HeaderMetadata import pydantic from pydantic import model_serializer -from typing import Any, List, Literal, Optional, Union +from typing import List, Literal, Optional, Union from typing_extensions import Annotated, NotRequired, TypeAliasType, TypedDict @@ -44,7 +44,7 @@ class CreatePlanGlobals(BaseModel): return m -CreatePlanPriceIntervalRequest = Literal[ +CreatePlanPriceIntervalRequestBody = Literal[ "one_off", "week", "month", @@ -55,24 +55,24 @@ CreatePlanPriceIntervalRequest = Literal[ r"""Billing interval (e.g. 'month', 'year').""" -class CreatePlanPriceRequestTypedDict(TypedDict): +class CreatePlanPriceRequestBodyTypedDict(TypedDict): r"""Base recurring price for the plan. Omit for free or usage-only plans.""" amount: float r"""Base price amount for the plan.""" - interval: CreatePlanPriceIntervalRequest + interval: CreatePlanPriceIntervalRequestBody r"""Billing interval (e.g. 'month', 'year').""" interval_count: NotRequired[float] r"""Number of intervals per billing cycle. Defaults to 1.""" -class CreatePlanPriceRequest(BaseModel): +class CreatePlanPriceRequestBody(BaseModel): r"""Base recurring price for the plan. Omit for free or usage-only plans.""" amount: float r"""Base price amount for the plan.""" - interval: CreatePlanPriceIntervalRequest + interval: CreatePlanPriceIntervalRequestBody r"""Billing interval (e.g. 'month', 'year').""" interval_count: Optional[float] = None @@ -95,7 +95,7 @@ class CreatePlanPriceRequest(BaseModel): return m -CreatePlanResetIntervalRequest = Literal[ +CreatePlanResetIntervalRequestBody = Literal[ "one_off", "minute", "hour", @@ -109,19 +109,19 @@ CreatePlanResetIntervalRequest = Literal[ r"""Interval at which balance resets (e.g. 'month', 'year'). For consumable features only.""" -class CreatePlanResetRequestTypedDict(TypedDict): +class CreatePlanResetRequestBodyTypedDict(TypedDict): r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" - interval: CreatePlanResetIntervalRequest + interval: CreatePlanResetIntervalRequestBody r"""Interval at which balance resets (e.g. 'month', 'year'). For consumable features only.""" interval_count: NotRequired[float] r"""Number of intervals between resets. Defaults to 1.""" -class CreatePlanResetRequest(BaseModel): +class CreatePlanResetRequestBody(BaseModel): r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" - interval: CreatePlanResetIntervalRequest + interval: CreatePlanResetIntervalRequestBody r"""Interval at which balance resets (e.g. 'month', 'year'). For consumable features only.""" interval_count: Optional[float] = None @@ -144,20 +144,22 @@ class CreatePlanResetRequest(BaseModel): return m -CreatePlanToTypedDict = TypeAliasType("CreatePlanToTypedDict", Union[float, str]) +CreatePlanToRequestBodyTypedDict = TypeAliasType( + "CreatePlanToRequestBodyTypedDict", Union[float, str] +) -CreatePlanTo = TypeAliasType("CreatePlanTo", Union[float, str]) +CreatePlanToRequestBody = TypeAliasType("CreatePlanToRequestBody", Union[float, str]) -class CreatePlanTierTypedDict(TypedDict): - to: CreatePlanToTypedDict +class CreatePlanTierRequestBodyTypedDict(TypedDict): + to: CreatePlanToRequestBodyTypedDict amount: NotRequired[float] flat_amount: NotRequired[float] -class CreatePlanTier(BaseModel): - to: CreatePlanTo +class CreatePlanTierRequestBody(BaseModel): + to: CreatePlanToRequestBody amount: Optional[float] = None @@ -180,13 +182,13 @@ class CreatePlanTier(BaseModel): return m -CreatePlanTierBehaviorRequest = Literal[ +CreatePlanTierBehaviorRequestBody = Literal[ "graduated", "volume", ] -CreatePlanItemPriceIntervalRequest = Literal[ +CreatePlanItemPriceIntervalRequestBody = Literal[ "one_off", "week", "month", @@ -197,49 +199,49 @@ CreatePlanItemPriceIntervalRequest = Literal[ r"""Billing interval. For consumable features, should match reset.interval.""" -CreatePlanBillingMethodRequest = Literal[ +CreatePlanBillingMethodRequestBody = Literal[ "prepaid", "usage_based", ] r"""'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go.""" -class CreatePlanItemPriceRequestTypedDict(TypedDict): +class CreatePlanItemPriceRequestBodyTypedDict(TypedDict): r"""Pricing for usage beyond included units. Omit for free features.""" - interval: CreatePlanItemPriceIntervalRequest + interval: CreatePlanItemPriceIntervalRequestBody r"""Billing interval. For consumable features, should match reset.interval.""" - billing_method: CreatePlanBillingMethodRequest + billing_method: CreatePlanBillingMethodRequestBody r"""'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go.""" amount: NotRequired[float] r"""Price per billing_units after included usage. Either 'amount' or 'tiers' is required.""" - tiers: NotRequired[List[CreatePlanTierTypedDict]] + tiers: NotRequired[List[CreatePlanTierRequestBodyTypedDict]] r"""Tiered pricing. Either 'amount' or 'tiers' is required.""" - tier_behavior: NotRequired[CreatePlanTierBehaviorRequest] + tier_behavior: NotRequired[CreatePlanTierBehaviorRequestBody] interval_count: NotRequired[float] r"""Number of intervals per billing cycle. Defaults to 1.""" billing_units: NotRequired[float] r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: NotRequired[float] - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" -class CreatePlanItemPriceRequest(BaseModel): +class CreatePlanItemPriceRequestBody(BaseModel): r"""Pricing for usage beyond included units. Omit for free features.""" - interval: CreatePlanItemPriceIntervalRequest + interval: CreatePlanItemPriceIntervalRequestBody r"""Billing interval. For consumable features, should match reset.interval.""" - billing_method: CreatePlanBillingMethodRequest + billing_method: CreatePlanBillingMethodRequestBody r"""'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go.""" amount: Optional[float] = None r"""Price per billing_units after included usage. Either 'amount' or 'tiers' is required.""" - tiers: Optional[List[CreatePlanTier]] = None + tiers: Optional[List[CreatePlanTierRequestBody]] = None r"""Tiered pricing. Either 'amount' or 'tiers' is required.""" - tier_behavior: Optional[CreatePlanTierBehaviorRequest] = None + tier_behavior: Optional[CreatePlanTierBehaviorRequestBody] = None interval_count: Optional[float] = 1 r"""Number of intervals per billing cycle. Defaults to 1.""" @@ -247,8 +249,8 @@ class CreatePlanItemPriceRequest(BaseModel): billing_units: Optional[float] = 1 r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: Optional[float] = None - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" @model_serializer(mode="wrap") def serialize_model(self, handler): @@ -262,15 +264,24 @@ class CreatePlanItemPriceRequest(BaseModel): "max_purchase", ] ) + nullable_fields = set(["max_purchase"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m @@ -314,17 +325,17 @@ class CreatePlanProration(BaseModel): r"""Credit behavior when quantity decreases mid-cycle.""" -CreatePlanExpiryDurationTypeRequest = Literal[ +CreatePlanExpiryDurationTypeRequestBody = Literal[ "month", "forever", ] r"""When rolled over units expire.""" -class CreatePlanRolloverRequestTypedDict(TypedDict): +class CreatePlanRolloverRequestBodyTypedDict(TypedDict): r"""Rollover config for unused units. If set, unused included units carry over.""" - expiry_duration_type: CreatePlanExpiryDurationTypeRequest + expiry_duration_type: CreatePlanExpiryDurationTypeRequestBody r"""When rolled over units expire.""" max: NotRequired[float] r"""Max rollover units. Omit for unlimited rollover.""" @@ -334,10 +345,10 @@ class CreatePlanRolloverRequestTypedDict(TypedDict): r"""Number of periods before expiry.""" -class CreatePlanRolloverRequest(BaseModel): +class CreatePlanRolloverRequestBody(BaseModel): r"""Rollover config for unused units. If set, unused included units carry over.""" - expiry_duration_type: CreatePlanExpiryDurationTypeRequest + expiry_duration_type: CreatePlanExpiryDurationTypeRequestBody r"""When rolled over units expire.""" max: Optional[float] = None @@ -375,13 +386,13 @@ class CreatePlanPlanItemTypedDict(TypedDict): r"""Number of free units included. Balance resets to this each interval for consumable features.""" unlimited: NotRequired[bool] r"""If true, customer has unlimited access to this feature.""" - reset: NotRequired[CreatePlanResetRequestTypedDict] + reset: NotRequired[CreatePlanResetRequestBodyTypedDict] r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" - price: NotRequired[CreatePlanItemPriceRequestTypedDict] + price: NotRequired[CreatePlanItemPriceRequestBodyTypedDict] r"""Pricing for usage beyond included units. Omit for free features.""" proration: NotRequired[CreatePlanProrationTypedDict] r"""Proration settings for prepaid features. Controls mid-cycle quantity change billing.""" - rollover: NotRequired[CreatePlanRolloverRequestTypedDict] + rollover: NotRequired[CreatePlanRolloverRequestBodyTypedDict] r"""Rollover config for unused units. If set, unused included units carry over.""" @@ -397,16 +408,16 @@ class CreatePlanPlanItem(BaseModel): unlimited: Optional[bool] = None r"""If true, customer has unlimited access to this feature.""" - reset: Optional[CreatePlanResetRequest] = None + reset: Optional[CreatePlanResetRequestBody] = None r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" - price: Optional[CreatePlanItemPriceRequest] = None + price: Optional[CreatePlanItemPriceRequestBody] = None r"""Pricing for usage beyond included units. Omit for free features.""" proration: Optional[CreatePlanProration] = None r"""Proration settings for prepaid features. Controls mid-cycle quantity change billing.""" - rollover: Optional[CreatePlanRolloverRequest] = None + rollover: Optional[CreatePlanRolloverRequestBody] = None r"""Rollover config for unused units. If set, unused included units carry over.""" @model_serializer(mode="wrap") @@ -531,7 +542,7 @@ class CreatePlanParamsTypedDict(TypedDict): r"""If true, this plan can be attached alongside other plans. Otherwise, attaching replaces existing plans in the same group.""" auto_enable: NotRequired[bool] r"""If true, plan is automatically attached when a customer is created. Use for free tiers.""" - price: NotRequired[CreatePlanPriceRequestTypedDict] + price: NotRequired[CreatePlanPriceRequestBodyTypedDict] r"""Base recurring price for the plan. Omit for free or usage-only plans.""" items: NotRequired[List[CreatePlanPlanItemTypedDict]] r"""Feature configurations for this plan. Each item defines included units, pricing, and reset behavior.""" @@ -561,7 +572,7 @@ class CreatePlanParams(BaseModel): auto_enable: Optional[bool] = False r"""If true, plan is automatically attached when a customer is created. Use for free tiers.""" - price: Optional[CreatePlanPriceRequest] = None + price: Optional[CreatePlanPriceRequestBody] = None r"""Base recurring price for the plan. Omit for free or usage-only plans.""" items: Optional[List[CreatePlanPlanItem]] = None @@ -710,6 +721,7 @@ CreatePlanType = Union[ "single_use", "continuous_use", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] @@ -858,6 +870,44 @@ class CreatePlanResetResponse(BaseModel): return m +CreatePlanToResponseTypedDict = TypeAliasType( + "CreatePlanToResponseTypedDict", Union[float, str] +) + + +CreatePlanToResponse = TypeAliasType("CreatePlanToResponse", Union[float, str]) + + +class CreatePlanTierResponseTypedDict(TypedDict): + to: CreatePlanToResponseTypedDict + amount: float + flat_amount: NotRequired[float] + + +class CreatePlanTierResponse(BaseModel): + to: CreatePlanToResponse + + amount: float + + flat_amount: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["flat_amount"]) + 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 + + CreatePlanTierBehaviorResponse = Union[ Literal[ "graduated", @@ -902,7 +952,7 @@ class CreatePlanItemPriceResponseTypedDict(TypedDict): r"""Maximum units a customer can purchase beyond included. E.g. if included=100 and max_purchase=300, customer can use up to 400 total before usage is capped. Null for no limit.""" amount: NotRequired[float] r"""Price per billing_units after included usage is consumed. Mutually exclusive with tiers.""" - tiers: NotRequired[List[Nullable[Any]]] + tiers: NotRequired[List[CreatePlanTierResponseTypedDict]] r"""Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.""" tier_behavior: NotRequired[CreatePlanTierBehaviorResponse] interval_count: NotRequired[float] @@ -925,7 +975,7 @@ class CreatePlanItemPriceResponse(BaseModel): amount: Optional[float] = None r"""Price per billing_units after included usage is consumed. Mutually exclusive with tiers.""" - tiers: Optional[List[Nullable[Any]]] = None + tiers: Optional[List[CreatePlanTierResponse]] = None r"""Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.""" tier_behavior: Optional[CreatePlanTierBehaviorResponse] = None diff --git a/others/python-sdk/src/autumn_sdk/models/createscheduleop.py b/others/python-sdk/src/autumn_sdk/models/createscheduleop.py index 201b77cbe..27a8f37ca 100644 --- a/others/python-sdk/src/autumn_sdk/models/createscheduleop.py +++ b/others/python-sdk/src/autumn_sdk/models/createscheduleop.py @@ -14,7 +14,7 @@ import pydantic from pydantic import model_serializer from pydantic.functional_validators import AfterValidator from typing import Any, Dict, List, Literal, Optional, Union -from typing_extensions import Annotated, NotRequired, TypedDict +from typing_extensions import Annotated, NotRequired, TypeAliasType, TypedDict class CreateScheduleGlobalsTypedDict(TypedDict): @@ -243,7 +243,7 @@ class CreateScheduleBasePrice2(BaseModel): return m -CreateScheduleResetInterval2 = Literal[ +CreateScheduleItemResetInterval2 = Literal[ "one_off", "minute", "hour", @@ -257,19 +257,19 @@ CreateScheduleResetInterval2 = Literal[ r"""Interval at which balance resets (e.g. 'month', 'year'). For consumable features only.""" -class CreateScheduleReset2TypedDict(TypedDict): +class CreateScheduleItemReset2TypedDict(TypedDict): r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" - interval: CreateScheduleResetInterval2 + interval: CreateScheduleItemResetInterval2 r"""Interval at which balance resets (e.g. 'month', 'year'). For consumable features only.""" interval_count: NotRequired[float] r"""Number of intervals between resets. Defaults to 1.""" -class CreateScheduleReset2(BaseModel): +class CreateScheduleItemReset2(BaseModel): r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" - interval: CreateScheduleResetInterval2 + interval: CreateScheduleItemResetInterval2 r"""Interval at which balance resets (e.g. 'month', 'year'). For consumable features only.""" interval_count: Optional[float] = None @@ -292,13 +292,13 @@ class CreateScheduleReset2(BaseModel): return m -class CreateScheduleTier2TypedDict(TypedDict): +class CreateScheduleItemTier2TypedDict(TypedDict): to: NotRequired[Any] amount: NotRequired[Any] flat_amount: NotRequired[Any] -class CreateScheduleTier2(BaseModel): +class CreateScheduleItemTier2(BaseModel): to: Optional[Any] = None amount: Optional[Any] = None @@ -322,7 +322,7 @@ class CreateScheduleTier2(BaseModel): return m -CreateScheduleTierBehavior2 = Literal[ +CreateScheduleItemTierBehavior2 = Literal[ "graduated", "volume", ] @@ -339,49 +339,49 @@ CreateScheduleItemPriceInterval2 = Literal[ r"""Billing interval. For consumable features, should match reset.interval.""" -CreateScheduleBillingMethod2 = Literal[ +CreateScheduleItemBillingMethod2 = Literal[ "prepaid", "usage_based", ] r"""'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go.""" -class CreateSchedulePrice2TypedDict(TypedDict): +class CreateScheduleItemPrice2TypedDict(TypedDict): r"""Pricing for usage beyond included units. Omit for free features.""" interval: CreateScheduleItemPriceInterval2 r"""Billing interval. For consumable features, should match reset.interval.""" - billing_method: CreateScheduleBillingMethod2 + billing_method: CreateScheduleItemBillingMethod2 r"""'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go.""" amount: NotRequired[float] r"""Price per billing_units after included usage. Either 'amount' or 'tiers' is required.""" - tiers: NotRequired[List[CreateScheduleTier2TypedDict]] + tiers: NotRequired[List[CreateScheduleItemTier2TypedDict]] r"""Tiered pricing. Either 'amount' or 'tiers' is required.""" - tier_behavior: NotRequired[CreateScheduleTierBehavior2] + tier_behavior: NotRequired[CreateScheduleItemTierBehavior2] interval_count: NotRequired[float] r"""Number of intervals per billing cycle. Defaults to 1.""" billing_units: NotRequired[float] r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: NotRequired[float] - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" -class CreateSchedulePrice2(BaseModel): +class CreateScheduleItemPrice2(BaseModel): r"""Pricing for usage beyond included units. Omit for free features.""" interval: CreateScheduleItemPriceInterval2 r"""Billing interval. For consumable features, should match reset.interval.""" - billing_method: CreateScheduleBillingMethod2 + billing_method: CreateScheduleItemBillingMethod2 r"""'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go.""" amount: Optional[float] = None r"""Price per billing_units after included usage. Either 'amount' or 'tiers' is required.""" - tiers: Optional[List[CreateScheduleTier2]] = None + tiers: Optional[List[CreateScheduleItemTier2]] = None r"""Tiered pricing. Either 'amount' or 'tiers' is required.""" - tier_behavior: Optional[CreateScheduleTierBehavior2] = None + tier_behavior: Optional[CreateScheduleItemTierBehavior2] = None interval_count: Optional[float] = 1 r"""Number of intervals per billing cycle. Defaults to 1.""" @@ -389,8 +389,8 @@ class CreateSchedulePrice2(BaseModel): billing_units: Optional[float] = 1 r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: Optional[float] = None - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" @model_serializer(mode="wrap") def serialize_model(self, handler): @@ -404,21 +404,30 @@ class CreateSchedulePrice2(BaseModel): "max_purchase", ] ) + nullable_fields = set(["max_purchase"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m -CreateScheduleOnIncrease2 = Literal[ +CreateScheduleItemOnIncrease2 = Literal[ "bill_immediately", "prorate_immediately", "prorate_next_cycle", @@ -427,7 +436,7 @@ CreateScheduleOnIncrease2 = Literal[ r"""Billing behavior when quantity increases mid-cycle.""" -CreateScheduleOnDecrease2 = Literal[ +CreateScheduleItemOnDecrease2 = Literal[ "prorate", "prorate_immediately", "prorate_next_cycle", @@ -437,36 +446,36 @@ CreateScheduleOnDecrease2 = Literal[ r"""Credit behavior when quantity decreases mid-cycle.""" -class CreateScheduleProration2TypedDict(TypedDict): +class CreateScheduleItemProration2TypedDict(TypedDict): r"""Proration settings for prepaid features. Controls mid-cycle quantity change billing.""" - on_increase: CreateScheduleOnIncrease2 + on_increase: CreateScheduleItemOnIncrease2 r"""Billing behavior when quantity increases mid-cycle.""" - on_decrease: CreateScheduleOnDecrease2 + on_decrease: CreateScheduleItemOnDecrease2 r"""Credit behavior when quantity decreases mid-cycle.""" -class CreateScheduleProration2(BaseModel): +class CreateScheduleItemProration2(BaseModel): r"""Proration settings for prepaid features. Controls mid-cycle quantity change billing.""" - on_increase: CreateScheduleOnIncrease2 + on_increase: CreateScheduleItemOnIncrease2 r"""Billing behavior when quantity increases mid-cycle.""" - on_decrease: CreateScheduleOnDecrease2 + on_decrease: CreateScheduleItemOnDecrease2 r"""Credit behavior when quantity decreases mid-cycle.""" -CreateScheduleExpiryDurationType2 = Literal[ +CreateScheduleItemExpiryDurationType2 = Literal[ "month", "forever", ] r"""When rolled over units expire.""" -class CreateScheduleRollover2TypedDict(TypedDict): +class CreateScheduleItemRollover2TypedDict(TypedDict): r"""Rollover config for unused units. If set, unused included units carry over.""" - expiry_duration_type: CreateScheduleExpiryDurationType2 + expiry_duration_type: CreateScheduleItemExpiryDurationType2 r"""When rolled over units expire.""" max: NotRequired[float] r"""Max rollover units. Omit for unlimited rollover.""" @@ -476,10 +485,10 @@ class CreateScheduleRollover2TypedDict(TypedDict): r"""Number of periods before expiry.""" -class CreateScheduleRollover2(BaseModel): +class CreateScheduleItemRollover2(BaseModel): r"""Rollover config for unused units. If set, unused included units carry over.""" - expiry_duration_type: CreateScheduleExpiryDurationType2 + expiry_duration_type: CreateScheduleItemExpiryDurationType2 r"""When rolled over units expire.""" max: Optional[float] = None @@ -508,7 +517,7 @@ class CreateScheduleRollover2(BaseModel): return m -class CreateSchedulePlanItem2TypedDict(TypedDict): +class CreateScheduleItemPlanItem2TypedDict(TypedDict): r"""Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings.""" feature_id: str @@ -517,17 +526,17 @@ class CreateSchedulePlanItem2TypedDict(TypedDict): r"""Number of free units included. Balance resets to this each interval for consumable features.""" unlimited: NotRequired[bool] r"""If true, customer has unlimited access to this feature.""" - reset: NotRequired[CreateScheduleReset2TypedDict] + reset: NotRequired[CreateScheduleItemReset2TypedDict] r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" - price: NotRequired[CreateSchedulePrice2TypedDict] + price: NotRequired[CreateScheduleItemPrice2TypedDict] r"""Pricing for usage beyond included units. Omit for free features.""" - proration: NotRequired[CreateScheduleProration2TypedDict] + proration: NotRequired[CreateScheduleItemProration2TypedDict] r"""Proration settings for prepaid features. Controls mid-cycle quantity change billing.""" - rollover: NotRequired[CreateScheduleRollover2TypedDict] + rollover: NotRequired[CreateScheduleItemRollover2TypedDict] r"""Rollover config for unused units. If set, unused included units carry over.""" -class CreateSchedulePlanItem2(BaseModel): +class CreateScheduleItemPlanItem2(BaseModel): r"""Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings.""" feature_id: str @@ -539,16 +548,16 @@ class CreateSchedulePlanItem2(BaseModel): unlimited: Optional[bool] = None r"""If true, customer has unlimited access to this feature.""" - reset: Optional[CreateScheduleReset2] = None + reset: Optional[CreateScheduleItemReset2] = None r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" - price: Optional[CreateSchedulePrice2] = None + price: Optional[CreateScheduleItemPrice2] = None r"""Pricing for usage beyond included units. Omit for free features.""" - proration: Optional[CreateScheduleProration2] = None + proration: Optional[CreateScheduleItemProration2] = None r"""Proration settings for prepaid features. Controls mid-cycle quantity change billing.""" - rollover: Optional[CreateScheduleRollover2] = None + rollover: Optional[CreateScheduleItemRollover2] = None r"""Rollover config for unused units. If set, unused included units carry over.""" @model_serializer(mode="wrap") @@ -570,27 +579,464 @@ class CreateSchedulePlanItem2(BaseModel): return m +CreateScheduleAddItemResetInterval2 = Literal[ + "one_off", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "semi_annual", + "year", +] +r"""Interval at which balance resets (e.g. 'month', 'year'). For consumable features only.""" + + +class CreateScheduleAddItemReset2TypedDict(TypedDict): + r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" + + interval: CreateScheduleAddItemResetInterval2 + r"""Interval at which balance resets (e.g. 'month', 'year'). For consumable features only.""" + interval_count: NotRequired[float] + r"""Number of intervals between resets. Defaults to 1.""" + + +class CreateScheduleAddItemReset2(BaseModel): + r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" + + interval: CreateScheduleAddItemResetInterval2 + r"""Interval at which balance resets (e.g. 'month', 'year'). For consumable features only.""" + + interval_count: Optional[float] = None + r"""Number of intervals between resets. Defaults to 1.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["interval_count"]) + 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 CreateScheduleAddItemTier2TypedDict(TypedDict): + to: NotRequired[Any] + amount: NotRequired[Any] + flat_amount: NotRequired[Any] + + +class CreateScheduleAddItemTier2(BaseModel): + to: Optional[Any] = None + + amount: Optional[Any] = None + + flat_amount: Optional[Any] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["to", "amount", "flat_amount"]) + 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 + + +CreateScheduleAddItemTierBehavior2 = Literal[ + "graduated", + "volume", +] + + +CreateScheduleAddItemPriceInterval2 = Literal[ + "one_off", + "week", + "month", + "quarter", + "semi_annual", + "year", +] +r"""Billing interval. For consumable features, should match reset.interval.""" + + +CreateScheduleAddItemBillingMethod2 = Literal[ + "prepaid", + "usage_based", +] +r"""'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go.""" + + +class CreateScheduleAddItemPrice2TypedDict(TypedDict): + r"""Pricing for usage beyond included units. Omit for free features.""" + + interval: CreateScheduleAddItemPriceInterval2 + r"""Billing interval. For consumable features, should match reset.interval.""" + billing_method: CreateScheduleAddItemBillingMethod2 + r"""'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go.""" + amount: NotRequired[float] + r"""Price per billing_units after included usage. Either 'amount' or 'tiers' is required.""" + tiers: NotRequired[List[CreateScheduleAddItemTier2TypedDict]] + r"""Tiered pricing. Either 'amount' or 'tiers' is required.""" + tier_behavior: NotRequired[CreateScheduleAddItemTierBehavior2] + interval_count: NotRequired[float] + r"""Number of intervals per billing cycle. Defaults to 1.""" + billing_units: NotRequired[float] + r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" + + +class CreateScheduleAddItemPrice2(BaseModel): + r"""Pricing for usage beyond included units. Omit for free features.""" + + interval: CreateScheduleAddItemPriceInterval2 + r"""Billing interval. For consumable features, should match reset.interval.""" + + billing_method: CreateScheduleAddItemBillingMethod2 + r"""'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go.""" + + amount: Optional[float] = None + r"""Price per billing_units after included usage. Either 'amount' or 'tiers' is required.""" + + tiers: Optional[List[CreateScheduleAddItemTier2]] = None + r"""Tiered pricing. Either 'amount' or 'tiers' is required.""" + + tier_behavior: Optional[CreateScheduleAddItemTierBehavior2] = None + + interval_count: Optional[float] = 1 + r"""Number of intervals per billing cycle. Defaults to 1.""" + + billing_units: Optional[float] = 1 + r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" + + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set( + [ + "amount", + "tiers", + "tier_behavior", + "interval_count", + "billing_units", + "max_purchase", + ] + ) + nullable_fields = set(["max_purchase"]) + 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)) + 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 + + return m + + +CreateScheduleAddItemOnIncrease2 = Literal[ + "bill_immediately", + "prorate_immediately", + "prorate_next_cycle", + "bill_next_cycle", +] +r"""Billing behavior when quantity increases mid-cycle.""" + + +CreateScheduleAddItemOnDecrease2 = Literal[ + "prorate", + "prorate_immediately", + "prorate_next_cycle", + "none", + "no_prorations", +] +r"""Credit behavior when quantity decreases mid-cycle.""" + + +class CreateScheduleAddItemProration2TypedDict(TypedDict): + r"""Proration settings for prepaid features. Controls mid-cycle quantity change billing.""" + + on_increase: CreateScheduleAddItemOnIncrease2 + r"""Billing behavior when quantity increases mid-cycle.""" + on_decrease: CreateScheduleAddItemOnDecrease2 + r"""Credit behavior when quantity decreases mid-cycle.""" + + +class CreateScheduleAddItemProration2(BaseModel): + r"""Proration settings for prepaid features. Controls mid-cycle quantity change billing.""" + + on_increase: CreateScheduleAddItemOnIncrease2 + r"""Billing behavior when quantity increases mid-cycle.""" + + on_decrease: CreateScheduleAddItemOnDecrease2 + r"""Credit behavior when quantity decreases mid-cycle.""" + + +CreateScheduleAddItemExpiryDurationType2 = Literal[ + "month", + "forever", +] +r"""When rolled over units expire.""" + + +class CreateScheduleAddItemRollover2TypedDict(TypedDict): + r"""Rollover config for unused units. If set, unused included units carry over.""" + + expiry_duration_type: CreateScheduleAddItemExpiryDurationType2 + r"""When rolled over units expire.""" + max: NotRequired[float] + r"""Max rollover units. Omit for unlimited rollover.""" + max_percentage: NotRequired[float] + r"""Maximum rollover as a percentage (0-100) of included + prepaid grant. Mutually exclusive with max.""" + expiry_duration_length: NotRequired[float] + r"""Number of periods before expiry.""" + + +class CreateScheduleAddItemRollover2(BaseModel): + r"""Rollover config for unused units. If set, unused included units carry over.""" + + expiry_duration_type: CreateScheduleAddItemExpiryDurationType2 + r"""When rolled over units expire.""" + + max: Optional[float] = None + r"""Max rollover units. Omit for unlimited rollover.""" + + max_percentage: Optional[float] = None + r"""Maximum rollover as a percentage (0-100) of included + prepaid grant. Mutually exclusive with max.""" + + expiry_duration_length: Optional[float] = None + r"""Number of periods before expiry.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["max", "max_percentage", "expiry_duration_length"]) + 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 CreateScheduleAddItemPlanItem2TypedDict(TypedDict): + r"""Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings.""" + + feature_id: str + r"""The ID of the feature to configure.""" + included: NotRequired[float] + r"""Number of free units included. Balance resets to this each interval for consumable features.""" + unlimited: NotRequired[bool] + r"""If true, customer has unlimited access to this feature.""" + reset: NotRequired[CreateScheduleAddItemReset2TypedDict] + r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" + price: NotRequired[CreateScheduleAddItemPrice2TypedDict] + r"""Pricing for usage beyond included units. Omit for free features.""" + proration: NotRequired[CreateScheduleAddItemProration2TypedDict] + r"""Proration settings for prepaid features. Controls mid-cycle quantity change billing.""" + rollover: NotRequired[CreateScheduleAddItemRollover2TypedDict] + r"""Rollover config for unused units. If set, unused included units carry over.""" + + +class CreateScheduleAddItemPlanItem2(BaseModel): + r"""Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings.""" + + feature_id: str + r"""The ID of the feature to configure.""" + + included: Optional[float] = None + r"""Number of free units included. Balance resets to this each interval for consumable features.""" + + unlimited: Optional[bool] = None + r"""If true, customer has unlimited access to this feature.""" + + reset: Optional[CreateScheduleAddItemReset2] = None + r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" + + price: Optional[CreateScheduleAddItemPrice2] = None + r"""Pricing for usage beyond included units. Omit for free features.""" + + proration: Optional[CreateScheduleAddItemProration2] = None + r"""Proration settings for prepaid features. Controls mid-cycle quantity change billing.""" + + rollover: Optional[CreateScheduleAddItemRollover2] = None + r"""Rollover config for unused units. If set, unused included units carry over.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set( + ["included", "unlimited", "reset", "price", "proration", "rollover"] + ) + 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 + + +CreateScheduleRemoveItemBillingMethod2 = Literal[ + "prepaid", + "usage_based", +] +r"""Match items with this billing method (prepaid or usage_based).""" + + +CreateScheduleIntervalRemoveItemEnum4 = Literal[ + "one_off", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "semi_annual", + "year", +] + + +CreateScheduleIntervalRemoveItemEnum3 = Literal[ + "one_off", + "week", + "month", + "quarter", + "semi_annual", + "year", +] + + +CreateScheduleIntervalUnion2TypedDict = TypeAliasType( + "CreateScheduleIntervalUnion2TypedDict", + Union[CreateScheduleIntervalRemoveItemEnum3, CreateScheduleIntervalRemoveItemEnum4], +) +r"""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.""" + + +CreateScheduleIntervalUnion2 = TypeAliasType( + "CreateScheduleIntervalUnion2", + Union[CreateScheduleIntervalRemoveItemEnum3, CreateScheduleIntervalRemoveItemEnum4], +) +r"""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.""" + + +class CreateSchedulePlanItemFilter2TypedDict(TypedDict): + r"""Filter for matching plan items. All provided fields must match (AND).""" + + feature_id: NotRequired[str] + r"""Match items linked to this feature.""" + billing_method: NotRequired[CreateScheduleRemoveItemBillingMethod2] + r"""Match items with this billing method (prepaid or usage_based).""" + interval: NotRequired[CreateScheduleIntervalUnion2TypedDict] + r"""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: NotRequired[int] + r"""Match items with this interval_count. Disambiguates between items that share an interval but differ in count.""" + + +class CreateSchedulePlanItemFilter2(BaseModel): + r"""Filter for matching plan items. All provided fields must match (AND).""" + + feature_id: Optional[str] = None + r"""Match items linked to this feature.""" + + billing_method: Optional[CreateScheduleRemoveItemBillingMethod2] = None + r"""Match items with this billing method (prepaid or usage_based).""" + + interval: Optional[CreateScheduleIntervalUnion2] = None + r"""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: Optional[int] = None + r"""Match items with this interval_count. Disambiguates between items that share an interval but differ in count.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set( + ["feature_id", "billing_method", "interval", "interval_count"] + ) + 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 CreateScheduleCustomize2TypedDict(TypedDict): - r"""Customize the plan to schedule. Can override the price, items, or both.""" + r"""Customize the plan to schedule. Can override price, replace items, or patch items with add_items, remove_items, and update_items.""" price: NotRequired[Nullable[CreateScheduleBasePrice2TypedDict]] r"""Override the base price of the plan. Pass null to remove the base price.""" - items: NotRequired[List[CreateSchedulePlanItem2TypedDict]] - r"""Override the items in the plan.""" + items: NotRequired[List[CreateScheduleItemPlanItem2TypedDict]] + r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.""" + add_items: NotRequired[List[CreateScheduleAddItemPlanItem2TypedDict]] + r"""Items to add to the plan.""" + remove_items: NotRequired[List[CreateSchedulePlanItemFilter2TypedDict]] + r"""Filters selecting items to remove from the plan.""" class CreateScheduleCustomize2(BaseModel): - r"""Customize the plan to schedule. Can override the price, items, or both.""" + r"""Customize the plan to schedule. Can override price, replace items, or patch items with add_items, remove_items, and update_items.""" price: OptionalNullable[CreateScheduleBasePrice2] = UNSET r"""Override the base price of the plan. Pass null to remove the base price.""" - items: Optional[List[CreateSchedulePlanItem2]] = None - r"""Override the items in the plan.""" + items: Optional[List[CreateScheduleItemPlanItem2]] = None + r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.""" + + add_items: Optional[List[CreateScheduleAddItemPlanItem2]] = None + r"""Items to add to the plan.""" + + remove_items: Optional[List[CreateSchedulePlanItemFilter2]] = None + r"""Filters selecting items to remove from the plan.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["price", "items"]) + optional_fields = set(["price", "items", "add_items", "remove_items"]) nullable_fields = set(["price"]) serialized = handler(self) m = {} @@ -622,7 +1068,7 @@ class CreateSchedulePlan2TypedDict(TypedDict): version: NotRequired[float] r"""Optional explicit plan version to schedule.""" customize: NotRequired[CreateScheduleCustomize2TypedDict] - r"""Customize the plan to schedule. Can override the price, items, or both.""" + r"""Customize the plan to schedule. Can override price, replace items, or patch items with add_items, remove_items, and update_items.""" subscription_id: NotRequired[str] r"""A unique ID to identify this subscription. Useful when scheduling the same plan multiple times.""" @@ -638,7 +1084,7 @@ class CreateSchedulePlan2(BaseModel): r"""Optional explicit plan version to schedule.""" customize: Optional[CreateScheduleCustomize2] = None - r"""Customize the plan to schedule. Can override the price, items, or both.""" + r"""Customize the plan to schedule. Can override price, replace items, or patch items with add_items, remove_items, and update_items.""" subscription_id: Optional[str] = None r"""A unique ID to identify this subscription. Useful when scheduling the same plan multiple times.""" diff --git a/others/python-sdk/src/autumn_sdk/models/customer.py b/others/python-sdk/src/autumn_sdk/models/customer.py index 47c3d131d..e6448f8eb 100644 --- a/others/python-sdk/src/autumn_sdk/models/customer.py +++ b/others/python-sdk/src/autumn_sdk/models/customer.py @@ -569,10 +569,11 @@ CustomerFlagsType = Union[ "boolean", "metered", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] -r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" +r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" class CustomerCreditSchemaTypedDict(TypedDict): @@ -590,6 +591,44 @@ class CustomerCreditSchema(BaseModel): r"""Credits consumed per unit of the metered feature.""" +class CustomerModelMarkupsTypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class CustomerModelMarkups(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 CustomerProviderMarkupsTypedDict(TypedDict): + markup: float + + +class CustomerProviderMarkups(BaseModel): + markup: float + + class CustomerDisplayTypedDict(TypedDict): r"""Display names for the feature in billing UI and customer-facing components.""" @@ -642,7 +681,7 @@ class CustomerFeatureTypedDict(TypedDict): name: str r"""Human-readable name displayed in the dashboard and billing UI.""" type: CustomerFlagsType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" archived: bool @@ -651,6 +690,12 @@ class CustomerFeatureTypedDict(TypedDict): r"""Event names that trigger this feature's balance. Allows multiple features to respond to a single event.""" credit_schema: NotRequired[List[CustomerCreditSchemaTypedDict]] r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: NotRequired[Nullable[Dict[str, CustomerModelMarkupsTypedDict]]] + r"""Per-model markup overrides for AI credit systems.""" + default_markup: NotRequired[float] + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + provider_markups: NotRequired[Nullable[Dict[str, CustomerProviderMarkupsTypedDict]]] + r"""Per-provider default markup percentages for AI credit systems.""" display: NotRequired[CustomerDisplayTypedDict] r"""Display names for the feature in billing UI and customer-facing components.""" @@ -665,7 +710,7 @@ class CustomerFeature(BaseModel): r"""Human-readable name displayed in the dashboard and billing UI.""" type: CustomerFlagsType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" @@ -679,21 +724,48 @@ class CustomerFeature(BaseModel): credit_schema: Optional[List[CustomerCreditSchema]] = None r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: OptionalNullable[Dict[str, CustomerModelMarkups]] = UNSET + r"""Per-model markup overrides for AI credit systems.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[Dict[str, CustomerProviderMarkups]] = UNSET + r"""Per-provider default markup percentages for AI credit systems.""" + display: Optional[CustomerDisplay] = None r"""Display names for the feature in billing UI and customer-facing components.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["event_names", "credit_schema", "display"]) + optional_fields = set( + [ + "event_names", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "display", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m diff --git a/others/python-sdk/src/autumn_sdk/models/getcustomerop.py b/others/python-sdk/src/autumn_sdk/models/getcustomerop.py index a4ec8bc47..121444d9b 100644 --- a/others/python-sdk/src/autumn_sdk/models/getcustomerop.py +++ b/others/python-sdk/src/autumn_sdk/models/getcustomerop.py @@ -632,10 +632,11 @@ GetCustomerFlagsType = Union[ "boolean", "metered", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] -r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" +r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" class GetCustomerCreditSchemaTypedDict(TypedDict): @@ -653,6 +654,44 @@ class GetCustomerCreditSchema(BaseModel): r"""Credits consumed per unit of the metered feature.""" +class GetCustomerModelMarkupsTypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class GetCustomerModelMarkups(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 GetCustomerProviderMarkupsTypedDict(TypedDict): + markup: float + + +class GetCustomerProviderMarkups(BaseModel): + markup: float + + class GetCustomerDisplayTypedDict(TypedDict): r"""Display names for the feature in billing UI and customer-facing components.""" @@ -705,7 +744,7 @@ class GetCustomerFeatureTypedDict(TypedDict): name: str r"""Human-readable name displayed in the dashboard and billing UI.""" type: GetCustomerFlagsType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" archived: bool @@ -714,6 +753,14 @@ class GetCustomerFeatureTypedDict(TypedDict): r"""Event names that trigger this feature's balance. Allows multiple features to respond to a single event.""" credit_schema: NotRequired[List[GetCustomerCreditSchemaTypedDict]] r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: NotRequired[Nullable[Dict[str, GetCustomerModelMarkupsTypedDict]]] + r"""Per-model markup overrides for AI credit systems.""" + default_markup: NotRequired[float] + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + provider_markups: NotRequired[ + Nullable[Dict[str, GetCustomerProviderMarkupsTypedDict]] + ] + r"""Per-provider default markup percentages for AI credit systems.""" display: NotRequired[GetCustomerDisplayTypedDict] r"""Display names for the feature in billing UI and customer-facing components.""" @@ -728,7 +775,7 @@ class GetCustomerFeature(BaseModel): r"""Human-readable name displayed in the dashboard and billing UI.""" type: GetCustomerFlagsType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" @@ -742,21 +789,48 @@ class GetCustomerFeature(BaseModel): credit_schema: Optional[List[GetCustomerCreditSchema]] = None r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: OptionalNullable[Dict[str, GetCustomerModelMarkups]] = UNSET + r"""Per-model markup overrides for AI credit systems.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[Dict[str, GetCustomerProviderMarkups]] = UNSET + r"""Per-provider default markup percentages for AI credit systems.""" + display: Optional[GetCustomerDisplay] = None r"""Display names for the feature in billing UI and customer-facing components.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["event_names", "credit_schema", "display"]) + optional_fields = set( + [ + "event_names", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "display", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m diff --git a/others/python-sdk/src/autumn_sdk/models/getentityop.py b/others/python-sdk/src/autumn_sdk/models/getentityop.py index 1dee20639..5d29deba9 100644 --- a/others/python-sdk/src/autumn_sdk/models/getentityop.py +++ b/others/python-sdk/src/autumn_sdk/models/getentityop.py @@ -291,10 +291,11 @@ GetEntityType = Union[ "boolean", "metered", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] -r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" +r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" class GetEntityCreditSchemaTypedDict(TypedDict): @@ -312,6 +313,44 @@ class GetEntityCreditSchema(BaseModel): r"""Credits consumed per unit of the metered feature.""" +class GetEntityModelMarkupsTypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class GetEntityModelMarkups(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 GetEntityProviderMarkupsTypedDict(TypedDict): + markup: float + + +class GetEntityProviderMarkups(BaseModel): + markup: float + + class GetEntityDisplayTypedDict(TypedDict): r"""Display names for the feature in billing UI and customer-facing components.""" @@ -364,7 +403,7 @@ class GetEntityFeatureTypedDict(TypedDict): name: str r"""Human-readable name displayed in the dashboard and billing UI.""" type: GetEntityType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" archived: bool @@ -373,6 +412,14 @@ class GetEntityFeatureTypedDict(TypedDict): r"""Event names that trigger this feature's balance. Allows multiple features to respond to a single event.""" credit_schema: NotRequired[List[GetEntityCreditSchemaTypedDict]] r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: NotRequired[Nullable[Dict[str, GetEntityModelMarkupsTypedDict]]] + r"""Per-model markup overrides for AI credit systems.""" + default_markup: NotRequired[float] + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + provider_markups: NotRequired[ + Nullable[Dict[str, GetEntityProviderMarkupsTypedDict]] + ] + r"""Per-provider default markup percentages for AI credit systems.""" display: NotRequired[GetEntityDisplayTypedDict] r"""Display names for the feature in billing UI and customer-facing components.""" @@ -387,7 +434,7 @@ class GetEntityFeature(BaseModel): r"""Human-readable name displayed in the dashboard and billing UI.""" type: GetEntityType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" @@ -401,21 +448,48 @@ class GetEntityFeature(BaseModel): credit_schema: Optional[List[GetEntityCreditSchema]] = None r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: OptionalNullable[Dict[str, GetEntityModelMarkups]] = UNSET + r"""Per-model markup overrides for AI credit systems.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[Dict[str, GetEntityProviderMarkups]] = UNSET + r"""Per-provider default markup percentages for AI credit systems.""" + display: Optional[GetEntityDisplay] = None r"""Display names for the feature in billing UI and customer-facing components.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["event_names", "credit_schema", "display"]) + optional_fields = set( + [ + "event_names", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "display", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m diff --git a/others/python-sdk/src/autumn_sdk/models/getfeatureop.py b/others/python-sdk/src/autumn_sdk/models/getfeatureop.py index a3b6d982c..e5644620f 100644 --- a/others/python-sdk/src/autumn_sdk/models/getfeatureop.py +++ b/others/python-sdk/src/autumn_sdk/models/getfeatureop.py @@ -12,7 +12,7 @@ from autumn_sdk.types import ( from autumn_sdk.utils import FieldMetadata, HeaderMetadata import pydantic from pydantic import model_serializer -from typing import List, Literal, Optional, Union +from typing import Dict, List, Literal, Optional, Union from typing_extensions import Annotated, NotRequired, TypedDict @@ -59,10 +59,11 @@ GetFeatureType = Union[ "boolean", "metered", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] -r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" +r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" class GetFeatureCreditSchemaTypedDict(TypedDict): @@ -80,6 +81,44 @@ class GetFeatureCreditSchema(BaseModel): r"""Credits consumed per unit of the metered feature.""" +class GetFeatureModelMarkupsTypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class GetFeatureModelMarkups(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 GetFeatureProviderMarkupsTypedDict(TypedDict): + markup: float + + +class GetFeatureProviderMarkups(BaseModel): + markup: float + + class GetFeatureDisplayTypedDict(TypedDict): r"""Display names for the feature in billing UI and customer-facing components.""" @@ -132,7 +171,7 @@ class GetFeatureResponseTypedDict(TypedDict): name: str r"""Human-readable name displayed in the dashboard and billing UI.""" type: GetFeatureType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" archived: bool @@ -141,6 +180,14 @@ class GetFeatureResponseTypedDict(TypedDict): r"""Event names that trigger this feature's balance. Allows multiple features to respond to a single event.""" credit_schema: NotRequired[List[GetFeatureCreditSchemaTypedDict]] r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: NotRequired[Nullable[Dict[str, GetFeatureModelMarkupsTypedDict]]] + r"""Per-model markup overrides for AI credit systems.""" + default_markup: NotRequired[float] + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + provider_markups: NotRequired[ + Nullable[Dict[str, GetFeatureProviderMarkupsTypedDict]] + ] + r"""Per-provider default markup percentages for AI credit systems.""" display: NotRequired[GetFeatureDisplayTypedDict] r"""Display names for the feature in billing UI and customer-facing components.""" @@ -155,7 +202,7 @@ class GetFeatureResponse(BaseModel): r"""Human-readable name displayed in the dashboard and billing UI.""" type: GetFeatureType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" @@ -169,21 +216,48 @@ class GetFeatureResponse(BaseModel): credit_schema: Optional[List[GetFeatureCreditSchema]] = None r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: OptionalNullable[Dict[str, GetFeatureModelMarkups]] = UNSET + r"""Per-model markup overrides for AI credit systems.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[Dict[str, GetFeatureProviderMarkups]] = UNSET + r"""Per-provider default markup percentages for AI credit systems.""" + display: Optional[GetFeatureDisplay] = None r"""Display names for the feature in billing UI and customer-facing components.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["event_names", "credit_schema", "display"]) + optional_fields = set( + [ + "event_names", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "display", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m diff --git a/others/python-sdk/src/autumn_sdk/models/getplanop.py b/others/python-sdk/src/autumn_sdk/models/getplanop.py index e619e8401..5db913303 100644 --- a/others/python-sdk/src/autumn_sdk/models/getplanop.py +++ b/others/python-sdk/src/autumn_sdk/models/getplanop.py @@ -12,8 +12,8 @@ from autumn_sdk.types import ( from autumn_sdk.utils import FieldMetadata, HeaderMetadata import pydantic from pydantic import model_serializer -from typing import Any, List, Literal, Optional, Union -from typing_extensions import Annotated, NotRequired, TypedDict +from typing import List, Literal, Optional, Union +from typing_extensions import Annotated, NotRequired, TypeAliasType, TypedDict class GetPlanGlobalsTypedDict(TypedDict): @@ -172,6 +172,7 @@ GetPlanType = Union[ "single_use", "continuous_use", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] @@ -320,6 +321,42 @@ class GetPlanReset(BaseModel): return m +GetPlanToTypedDict = TypeAliasType("GetPlanToTypedDict", Union[float, str]) + + +GetPlanTo = TypeAliasType("GetPlanTo", Union[float, str]) + + +class GetPlanTierTypedDict(TypedDict): + to: GetPlanToTypedDict + amount: float + flat_amount: NotRequired[float] + + +class GetPlanTier(BaseModel): + to: GetPlanTo + + amount: float + + flat_amount: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["flat_amount"]) + 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 + + GetPlanTierBehavior = Union[ Literal[ "graduated", @@ -364,7 +401,7 @@ class GetPlanItemPriceTypedDict(TypedDict): r"""Maximum units a customer can purchase beyond included. E.g. if included=100 and max_purchase=300, customer can use up to 400 total before usage is capped. Null for no limit.""" amount: NotRequired[float] r"""Price per billing_units after included usage is consumed. Mutually exclusive with tiers.""" - tiers: NotRequired[List[Nullable[Any]]] + tiers: NotRequired[List[GetPlanTierTypedDict]] r"""Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.""" tier_behavior: NotRequired[GetPlanTierBehavior] interval_count: NotRequired[float] @@ -387,7 +424,7 @@ class GetPlanItemPrice(BaseModel): amount: Optional[float] = None r"""Price per billing_units after included usage is consumed. Mutually exclusive with tiers.""" - tiers: Optional[List[Nullable[Any]]] = None + tiers: Optional[List[GetPlanTier]] = None r"""Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.""" tier_behavior: Optional[GetPlanTierBehavior] = None diff --git a/others/python-sdk/src/autumn_sdk/models/listcustomersop.py b/others/python-sdk/src/autumn_sdk/models/listcustomersop.py index 732fd3a87..684a59d0c 100644 --- a/others/python-sdk/src/autumn_sdk/models/listcustomersop.py +++ b/others/python-sdk/src/autumn_sdk/models/listcustomersop.py @@ -701,10 +701,11 @@ ListCustomersType = Union[ "boolean", "metered", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] -r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" +r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" class ListCustomersCreditSchemaTypedDict(TypedDict): @@ -722,6 +723,44 @@ class ListCustomersCreditSchema(BaseModel): r"""Credits consumed per unit of the metered feature.""" +class ListCustomersModelMarkupsTypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class ListCustomersModelMarkups(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 ListCustomersProviderMarkupsTypedDict(TypedDict): + markup: float + + +class ListCustomersProviderMarkups(BaseModel): + markup: float + + class ListCustomersDisplayTypedDict(TypedDict): r"""Display names for the feature in billing UI and customer-facing components.""" @@ -774,7 +813,7 @@ class ListCustomersFeatureTypedDict(TypedDict): name: str r"""Human-readable name displayed in the dashboard and billing UI.""" type: ListCustomersType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" archived: bool @@ -783,6 +822,14 @@ class ListCustomersFeatureTypedDict(TypedDict): r"""Event names that trigger this feature's balance. Allows multiple features to respond to a single event.""" credit_schema: NotRequired[List[ListCustomersCreditSchemaTypedDict]] r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: NotRequired[Nullable[Dict[str, ListCustomersModelMarkupsTypedDict]]] + r"""Per-model markup overrides for AI credit systems.""" + default_markup: NotRequired[float] + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + provider_markups: NotRequired[ + Nullable[Dict[str, ListCustomersProviderMarkupsTypedDict]] + ] + r"""Per-provider default markup percentages for AI credit systems.""" display: NotRequired[ListCustomersDisplayTypedDict] r"""Display names for the feature in billing UI and customer-facing components.""" @@ -797,7 +844,7 @@ class ListCustomersFeature(BaseModel): r"""Human-readable name displayed in the dashboard and billing UI.""" type: ListCustomersType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" @@ -811,21 +858,48 @@ class ListCustomersFeature(BaseModel): credit_schema: Optional[List[ListCustomersCreditSchema]] = None r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: OptionalNullable[Dict[str, ListCustomersModelMarkups]] = UNSET + r"""Per-model markup overrides for AI credit systems.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[Dict[str, ListCustomersProviderMarkups]] = UNSET + r"""Per-provider default markup percentages for AI credit systems.""" + display: Optional[ListCustomersDisplay] = None r"""Display names for the feature in billing UI and customer-facing components.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["event_names", "credit_schema", "display"]) + optional_fields = set( + [ + "event_names", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "display", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m diff --git a/others/python-sdk/src/autumn_sdk/models/listentitiesop.py b/others/python-sdk/src/autumn_sdk/models/listentitiesop.py index dfbd606cf..59dbb9d6a 100644 --- a/others/python-sdk/src/autumn_sdk/models/listentitiesop.py +++ b/others/python-sdk/src/autumn_sdk/models/listentitiesop.py @@ -367,10 +367,11 @@ ListEntitiesType = Union[ "boolean", "metered", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] -r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" +r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" class ListEntitiesCreditSchemaTypedDict(TypedDict): @@ -388,6 +389,44 @@ class ListEntitiesCreditSchema(BaseModel): r"""Credits consumed per unit of the metered feature.""" +class ListEntitiesModelMarkupsTypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class ListEntitiesModelMarkups(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 ListEntitiesProviderMarkupsTypedDict(TypedDict): + markup: float + + +class ListEntitiesProviderMarkups(BaseModel): + markup: float + + class ListEntitiesDisplayTypedDict(TypedDict): r"""Display names for the feature in billing UI and customer-facing components.""" @@ -440,7 +479,7 @@ class ListEntitiesFeatureTypedDict(TypedDict): name: str r"""Human-readable name displayed in the dashboard and billing UI.""" type: ListEntitiesType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" archived: bool @@ -449,6 +488,14 @@ class ListEntitiesFeatureTypedDict(TypedDict): r"""Event names that trigger this feature's balance. Allows multiple features to respond to a single event.""" credit_schema: NotRequired[List[ListEntitiesCreditSchemaTypedDict]] r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: NotRequired[Nullable[Dict[str, ListEntitiesModelMarkupsTypedDict]]] + r"""Per-model markup overrides for AI credit systems.""" + default_markup: NotRequired[float] + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + provider_markups: NotRequired[ + Nullable[Dict[str, ListEntitiesProviderMarkupsTypedDict]] + ] + r"""Per-provider default markup percentages for AI credit systems.""" display: NotRequired[ListEntitiesDisplayTypedDict] r"""Display names for the feature in billing UI and customer-facing components.""" @@ -463,7 +510,7 @@ class ListEntitiesFeature(BaseModel): r"""Human-readable name displayed in the dashboard and billing UI.""" type: ListEntitiesType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" @@ -477,21 +524,48 @@ class ListEntitiesFeature(BaseModel): credit_schema: Optional[List[ListEntitiesCreditSchema]] = None r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: OptionalNullable[Dict[str, ListEntitiesModelMarkups]] = UNSET + r"""Per-model markup overrides for AI credit systems.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[Dict[str, ListEntitiesProviderMarkups]] = UNSET + r"""Per-provider default markup percentages for AI credit systems.""" + display: Optional[ListEntitiesDisplay] = None r"""Display names for the feature in billing UI and customer-facing components.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["event_names", "credit_schema", "display"]) + optional_fields = set( + [ + "event_names", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "display", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m diff --git a/others/python-sdk/src/autumn_sdk/models/listfeaturesop.py b/others/python-sdk/src/autumn_sdk/models/listfeaturesop.py index 8819d5dc8..b5077df0f 100644 --- a/others/python-sdk/src/autumn_sdk/models/listfeaturesop.py +++ b/others/python-sdk/src/autumn_sdk/models/listfeaturesop.py @@ -12,7 +12,7 @@ from autumn_sdk.types import ( from autumn_sdk.utils import FieldMetadata, HeaderMetadata import pydantic from pydantic import model_serializer -from typing import List, Literal, Optional, Union +from typing import Dict, List, Literal, Optional, Union from typing_extensions import Annotated, NotRequired, TypedDict @@ -57,10 +57,11 @@ ListFeaturesType = Union[ "boolean", "metered", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] -r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" +r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" class ListFeaturesCreditSchemaTypedDict(TypedDict): @@ -78,6 +79,44 @@ class ListFeaturesCreditSchema(BaseModel): r"""Credits consumed per unit of the metered feature.""" +class ListFeaturesModelMarkupsTypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class ListFeaturesModelMarkups(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 ListFeaturesProviderMarkupsTypedDict(TypedDict): + markup: float + + +class ListFeaturesProviderMarkups(BaseModel): + markup: float + + class ListFeaturesDisplayTypedDict(TypedDict): r"""Display names for the feature in billing UI and customer-facing components.""" @@ -128,7 +167,7 @@ class ListFeaturesListTypedDict(TypedDict): name: str r"""Human-readable name displayed in the dashboard and billing UI.""" type: ListFeaturesType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" archived: bool @@ -137,6 +176,14 @@ class ListFeaturesListTypedDict(TypedDict): r"""Event names that trigger this feature's balance. Allows multiple features to respond to a single event.""" credit_schema: NotRequired[List[ListFeaturesCreditSchemaTypedDict]] r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: NotRequired[Nullable[Dict[str, ListFeaturesModelMarkupsTypedDict]]] + r"""Per-model markup overrides for AI credit systems.""" + default_markup: NotRequired[float] + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + provider_markups: NotRequired[ + Nullable[Dict[str, ListFeaturesProviderMarkupsTypedDict]] + ] + r"""Per-provider default markup percentages for AI credit systems.""" display: NotRequired[ListFeaturesDisplayTypedDict] r"""Display names for the feature in billing UI and customer-facing components.""" @@ -149,7 +196,7 @@ class ListFeaturesList(BaseModel): r"""Human-readable name displayed in the dashboard and billing UI.""" type: ListFeaturesType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" @@ -163,21 +210,48 @@ class ListFeaturesList(BaseModel): credit_schema: Optional[List[ListFeaturesCreditSchema]] = None r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: OptionalNullable[Dict[str, ListFeaturesModelMarkups]] = UNSET + r"""Per-model markup overrides for AI credit systems.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[Dict[str, ListFeaturesProviderMarkups]] = UNSET + r"""Per-provider default markup percentages for AI credit systems.""" + display: Optional[ListFeaturesDisplay] = None r"""Display names for the feature in billing UI and customer-facing components.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["event_names", "credit_schema", "display"]) + optional_fields = set( + [ + "event_names", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "display", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m diff --git a/others/python-sdk/src/autumn_sdk/models/listplansop.py b/others/python-sdk/src/autumn_sdk/models/listplansop.py index cc0858949..e26e0984d 100644 --- a/others/python-sdk/src/autumn_sdk/models/listplansop.py +++ b/others/python-sdk/src/autumn_sdk/models/listplansop.py @@ -12,8 +12,8 @@ from autumn_sdk.types import ( from autumn_sdk.utils import FieldMetadata, HeaderMetadata import pydantic from pydantic import model_serializer -from typing import Any, List, Literal, Optional, Union -from typing_extensions import Annotated, NotRequired, TypedDict +from typing import List, Literal, Optional, Union +from typing_extensions import Annotated, NotRequired, TypeAliasType, TypedDict class ListPlansGlobalsTypedDict(TypedDict): @@ -177,6 +177,7 @@ ListPlansType = Union[ "single_use", "continuous_use", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] @@ -325,6 +326,42 @@ class ListPlansReset(BaseModel): return m +ListPlansToTypedDict = TypeAliasType("ListPlansToTypedDict", Union[float, str]) + + +ListPlansTo = TypeAliasType("ListPlansTo", Union[float, str]) + + +class ListPlansTierTypedDict(TypedDict): + to: ListPlansToTypedDict + amount: float + flat_amount: NotRequired[float] + + +class ListPlansTier(BaseModel): + to: ListPlansTo + + amount: float + + flat_amount: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["flat_amount"]) + 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 + + ListPlansTierBehavior = Union[ Literal[ "graduated", @@ -369,7 +406,7 @@ class ListPlansItemPriceTypedDict(TypedDict): r"""Maximum units a customer can purchase beyond included. E.g. if included=100 and max_purchase=300, customer can use up to 400 total before usage is capped. Null for no limit.""" amount: NotRequired[float] r"""Price per billing_units after included usage is consumed. Mutually exclusive with tiers.""" - tiers: NotRequired[List[Nullable[Any]]] + tiers: NotRequired[List[ListPlansTierTypedDict]] r"""Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.""" tier_behavior: NotRequired[ListPlansTierBehavior] interval_count: NotRequired[float] @@ -392,7 +429,7 @@ class ListPlansItemPrice(BaseModel): amount: Optional[float] = None r"""Price per billing_units after included usage is consumed. Mutually exclusive with tiers.""" - tiers: Optional[List[Nullable[Any]]] = None + tiers: Optional[List[ListPlansTier]] = None r"""Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.""" tier_behavior: Optional[ListPlansTierBehavior] = None diff --git a/others/python-sdk/src/autumn_sdk/models/multiattachop.py b/others/python-sdk/src/autumn_sdk/models/multiattachop.py index ece0d519e..ccfe5c9bd 100644 --- a/others/python-sdk/src/autumn_sdk/models/multiattachop.py +++ b/others/python-sdk/src/autumn_sdk/models/multiattachop.py @@ -221,8 +221,8 @@ class MultiAttachPriceTypedDict(TypedDict): r"""Number of intervals per billing cycle. Defaults to 1.""" billing_units: NotRequired[float] r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: NotRequired[float] - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" class MultiAttachPrice(BaseModel): @@ -248,8 +248,8 @@ class MultiAttachPrice(BaseModel): billing_units: Optional[float] = 1 r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: Optional[float] = None - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" @model_serializer(mode="wrap") def serialize_model(self, handler): @@ -263,15 +263,24 @@ class MultiAttachPrice(BaseModel): "max_purchase", ] ) + nullable_fields = set(["max_purchase"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m diff --git a/others/python-sdk/src/autumn_sdk/models/plan.py b/others/python-sdk/src/autumn_sdk/models/plan.py index 99032117d..ec2a59fd8 100644 --- a/others/python-sdk/src/autumn_sdk/models/plan.py +++ b/others/python-sdk/src/autumn_sdk/models/plan.py @@ -10,8 +10,8 @@ from autumn_sdk.types import ( UnrecognizedStr, ) from pydantic import model_serializer -from typing import Any, List, Literal, Optional, Union -from typing_extensions import NotRequired, TypedDict +from typing import List, Literal, Optional, Union +from typing_extensions import NotRequired, TypeAliasType, TypedDict PlanPriceInterval = Union[ @@ -111,6 +111,7 @@ PlanType = Union[ "single_use", "continuous_use", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] @@ -259,6 +260,42 @@ class PlanReset(BaseModel): return m +PlanToTypedDict = TypeAliasType("PlanToTypedDict", Union[float, str]) + + +PlanTo = TypeAliasType("PlanTo", Union[float, str]) + + +class PlanTierTypedDict(TypedDict): + to: PlanToTypedDict + amount: float + flat_amount: NotRequired[float] + + +class PlanTier(BaseModel): + to: PlanTo + + amount: float + + flat_amount: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["flat_amount"]) + 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 + + PlanTierBehavior = Union[ Literal[ "graduated", @@ -303,7 +340,7 @@ class PlanItemPriceTypedDict(TypedDict): r"""Maximum units a customer can purchase beyond included. E.g. if included=100 and max_purchase=300, customer can use up to 400 total before usage is capped. Null for no limit.""" amount: NotRequired[float] r"""Price per billing_units after included usage is consumed. Mutually exclusive with tiers.""" - tiers: NotRequired[List[Nullable[Any]]] + tiers: NotRequired[List[PlanTierTypedDict]] r"""Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.""" tier_behavior: NotRequired[PlanTierBehavior] interval_count: NotRequired[float] @@ -326,7 +363,7 @@ class PlanItemPrice(BaseModel): amount: Optional[float] = None r"""Price per billing_units after included usage is consumed. Mutually exclusive with tiers.""" - tiers: Optional[List[Nullable[Any]]] = None + tiers: Optional[List[PlanTier]] = None r"""Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.""" tier_behavior: Optional[PlanTierBehavior] = None diff --git a/others/python-sdk/src/autumn_sdk/models/previewattachop.py b/others/python-sdk/src/autumn_sdk/models/previewattachop.py index b43973c40..62192215f 100644 --- a/others/python-sdk/src/autumn_sdk/models/previewattachop.py +++ b/others/python-sdk/src/autumn_sdk/models/previewattachop.py @@ -264,8 +264,8 @@ class PreviewAttachItemPriceTypedDict(TypedDict): r"""Number of intervals per billing cycle. Defaults to 1.""" billing_units: NotRequired[float] r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: NotRequired[float] - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" class PreviewAttachItemPrice(BaseModel): @@ -291,8 +291,8 @@ class PreviewAttachItemPrice(BaseModel): billing_units: Optional[float] = 1 r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: Optional[float] = None - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" @model_serializer(mode="wrap") def serialize_model(self, handler): @@ -306,15 +306,24 @@ class PreviewAttachItemPrice(BaseModel): "max_purchase", ] ) + nullable_fields = set(["max_purchase"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m @@ -599,8 +608,8 @@ class PreviewAttachAddItemPriceTypedDict(TypedDict): r"""Number of intervals per billing cycle. Defaults to 1.""" billing_units: NotRequired[float] r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: NotRequired[float] - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" class PreviewAttachAddItemPrice(BaseModel): @@ -626,8 +635,8 @@ class PreviewAttachAddItemPrice(BaseModel): billing_units: Optional[float] = 1 r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: Optional[float] = None - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" @model_serializer(mode="wrap") def serialize_model(self, handler): @@ -641,15 +650,24 @@ class PreviewAttachAddItemPrice(BaseModel): "max_purchase", ] ) + nullable_fields = set(["max_purchase"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m @@ -814,7 +832,20 @@ PreviewAttachRemoveItemBillingMethod = Literal[ r"""Match items with this billing method (prepaid or usage_based).""" -PreviewAttachRemoveItemInterval = Literal[ +PreviewAttachIntervalRemoveItemEnum2 = Literal[ + "one_off", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "semi_annual", + "year", +] + + +PreviewAttachIntervalRemoveItemEnum1 = Literal[ "one_off", "week", "month", @@ -822,7 +853,20 @@ PreviewAttachRemoveItemInterval = Literal[ "semi_annual", "year", ] -r"""Match items with this interval.""" + + +PreviewAttachIntervalUnionTypedDict = TypeAliasType( + "PreviewAttachIntervalUnionTypedDict", + Union[PreviewAttachIntervalRemoveItemEnum1, PreviewAttachIntervalRemoveItemEnum2], +) +r"""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.""" + + +PreviewAttachIntervalUnion = TypeAliasType( + "PreviewAttachIntervalUnion", + Union[PreviewAttachIntervalRemoveItemEnum1, PreviewAttachIntervalRemoveItemEnum2], +) +r"""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.""" class PreviewAttachPlanItemFilterTypedDict(TypedDict): @@ -832,8 +876,10 @@ class PreviewAttachPlanItemFilterTypedDict(TypedDict): r"""Match items linked to this feature.""" billing_method: NotRequired[PreviewAttachRemoveItemBillingMethod] r"""Match items with this billing method (prepaid or usage_based).""" - interval: NotRequired[PreviewAttachRemoveItemInterval] - r"""Match items with this interval.""" + interval: NotRequired[PreviewAttachIntervalUnionTypedDict] + r"""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: NotRequired[int] + r"""Match items with this interval_count. Disambiguates between items that share an interval but differ in count.""" class PreviewAttachPlanItemFilter(BaseModel): @@ -845,12 +891,17 @@ class PreviewAttachPlanItemFilter(BaseModel): billing_method: Optional[PreviewAttachRemoveItemBillingMethod] = None r"""Match items with this billing method (prepaid or usage_based).""" - interval: Optional[PreviewAttachRemoveItemInterval] = None - r"""Match items with this interval.""" + interval: Optional[PreviewAttachIntervalUnion] = None + r"""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: Optional[int] = None + r"""Match items with this interval_count. Disambiguates between items that share an interval but differ in count.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["feature_id", "billing_method", "interval"]) + optional_fields = set( + ["feature_id", "billing_method", "interval", "interval_count"] + ) serialized = handler(self) m = {} @@ -931,7 +982,7 @@ class PreviewAttachCustomizeTypedDict(TypedDict): price: NotRequired[Nullable[PreviewAttachBasePriceTypedDict]] r"""Override the base price of the plan. Pass null to remove the base price.""" items: NotRequired[List[PreviewAttachItemPlanItemTypedDict]] - r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.""" + r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.""" add_items: NotRequired[List[PreviewAttachAddItemPlanItemTypedDict]] r"""Items to add to the plan.""" remove_items: NotRequired[List[PreviewAttachPlanItemFilterTypedDict]] @@ -947,7 +998,7 @@ class PreviewAttachCustomize(BaseModel): r"""Override the base price of the plan. Pass null to remove the base price.""" items: Optional[List[PreviewAttachItemPlanItem]] = None - r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.""" + r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.""" add_items: Optional[List[PreviewAttachAddItemPlanItem]] = None r"""Items to add to the plan.""" diff --git a/others/python-sdk/src/autumn_sdk/models/previewmultiattachop.py b/others/python-sdk/src/autumn_sdk/models/previewmultiattachop.py index 3af175b0b..614ed006d 100644 --- a/others/python-sdk/src/autumn_sdk/models/previewmultiattachop.py +++ b/others/python-sdk/src/autumn_sdk/models/previewmultiattachop.py @@ -224,8 +224,8 @@ class PreviewMultiAttachPriceTypedDict(TypedDict): r"""Number of intervals per billing cycle. Defaults to 1.""" billing_units: NotRequired[float] r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: NotRequired[float] - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" class PreviewMultiAttachPrice(BaseModel): @@ -251,8 +251,8 @@ class PreviewMultiAttachPrice(BaseModel): billing_units: Optional[float] = 1 r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: Optional[float] = None - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" @model_serializer(mode="wrap") def serialize_model(self, handler): @@ -266,15 +266,24 @@ class PreviewMultiAttachPrice(BaseModel): "max_purchase", ] ) + nullable_fields = set(["max_purchase"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m diff --git a/others/python-sdk/src/autumn_sdk/models/previewupdateop.py b/others/python-sdk/src/autumn_sdk/models/previewupdateop.py index dd5c8717d..fc079a344 100644 --- a/others/python-sdk/src/autumn_sdk/models/previewupdateop.py +++ b/others/python-sdk/src/autumn_sdk/models/previewupdateop.py @@ -264,8 +264,8 @@ class PreviewUpdateItemPriceTypedDict(TypedDict): r"""Number of intervals per billing cycle. Defaults to 1.""" billing_units: NotRequired[float] r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: NotRequired[float] - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" class PreviewUpdateItemPrice(BaseModel): @@ -291,8 +291,8 @@ class PreviewUpdateItemPrice(BaseModel): billing_units: Optional[float] = 1 r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: Optional[float] = None - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" @model_serializer(mode="wrap") def serialize_model(self, handler): @@ -306,15 +306,24 @@ class PreviewUpdateItemPrice(BaseModel): "max_purchase", ] ) + nullable_fields = set(["max_purchase"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m @@ -599,8 +608,8 @@ class PreviewUpdateAddItemPriceTypedDict(TypedDict): r"""Number of intervals per billing cycle. Defaults to 1.""" billing_units: NotRequired[float] r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: NotRequired[float] - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" class PreviewUpdateAddItemPrice(BaseModel): @@ -626,8 +635,8 @@ class PreviewUpdateAddItemPrice(BaseModel): billing_units: Optional[float] = 1 r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: Optional[float] = None - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" @model_serializer(mode="wrap") def serialize_model(self, handler): @@ -641,15 +650,24 @@ class PreviewUpdateAddItemPrice(BaseModel): "max_purchase", ] ) + nullable_fields = set(["max_purchase"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m @@ -814,7 +832,20 @@ PreviewUpdateRemoveItemBillingMethod = Literal[ r"""Match items with this billing method (prepaid or usage_based).""" -PreviewUpdateRemoveItemInterval = Literal[ +PreviewUpdateIntervalRemoveItemEnum2 = Literal[ + "one_off", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "semi_annual", + "year", +] + + +PreviewUpdateIntervalRemoveItemEnum1 = Literal[ "one_off", "week", "month", @@ -822,7 +853,20 @@ PreviewUpdateRemoveItemInterval = Literal[ "semi_annual", "year", ] -r"""Match items with this interval.""" + + +PreviewUpdateIntervalUnionTypedDict = TypeAliasType( + "PreviewUpdateIntervalUnionTypedDict", + Union[PreviewUpdateIntervalRemoveItemEnum1, PreviewUpdateIntervalRemoveItemEnum2], +) +r"""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.""" + + +PreviewUpdateIntervalUnion = TypeAliasType( + "PreviewUpdateIntervalUnion", + Union[PreviewUpdateIntervalRemoveItemEnum1, PreviewUpdateIntervalRemoveItemEnum2], +) +r"""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.""" class PreviewUpdatePlanItemFilterTypedDict(TypedDict): @@ -832,8 +876,10 @@ class PreviewUpdatePlanItemFilterTypedDict(TypedDict): r"""Match items linked to this feature.""" billing_method: NotRequired[PreviewUpdateRemoveItemBillingMethod] r"""Match items with this billing method (prepaid or usage_based).""" - interval: NotRequired[PreviewUpdateRemoveItemInterval] - r"""Match items with this interval.""" + interval: NotRequired[PreviewUpdateIntervalUnionTypedDict] + r"""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: NotRequired[int] + r"""Match items with this interval_count. Disambiguates between items that share an interval but differ in count.""" class PreviewUpdatePlanItemFilter(BaseModel): @@ -845,12 +891,17 @@ class PreviewUpdatePlanItemFilter(BaseModel): billing_method: Optional[PreviewUpdateRemoveItemBillingMethod] = None r"""Match items with this billing method (prepaid or usage_based).""" - interval: Optional[PreviewUpdateRemoveItemInterval] = None - r"""Match items with this interval.""" + interval: Optional[PreviewUpdateIntervalUnion] = None + r"""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: Optional[int] = None + r"""Match items with this interval_count. Disambiguates between items that share an interval but differ in count.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["feature_id", "billing_method", "interval"]) + optional_fields = set( + ["feature_id", "billing_method", "interval", "interval_count"] + ) serialized = handler(self) m = {} @@ -931,7 +982,7 @@ class PreviewUpdateCustomizeTypedDict(TypedDict): price: NotRequired[Nullable[PreviewUpdateBasePriceTypedDict]] r"""Override the base price of the plan. Pass null to remove the base price.""" items: NotRequired[List[PreviewUpdateItemPlanItemTypedDict]] - r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.""" + r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.""" add_items: NotRequired[List[PreviewUpdateAddItemPlanItemTypedDict]] r"""Items to add to the plan.""" remove_items: NotRequired[List[PreviewUpdatePlanItemFilterTypedDict]] @@ -947,7 +998,7 @@ class PreviewUpdateCustomize(BaseModel): r"""Override the base price of the plan. Pass null to remove the base price.""" items: Optional[List[PreviewUpdateItemPlanItem]] = None - r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.""" + r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.""" add_items: Optional[List[PreviewUpdateAddItemPlanItem]] = None r"""Items to add to the plan.""" diff --git a/others/python-sdk/src/autumn_sdk/models/setuppaymentop.py b/others/python-sdk/src/autumn_sdk/models/setuppaymentop.py index 417d43b99..592b57ded 100644 --- a/others/python-sdk/src/autumn_sdk/models/setuppaymentop.py +++ b/others/python-sdk/src/autumn_sdk/models/setuppaymentop.py @@ -262,8 +262,8 @@ class SetupPaymentItemPriceTypedDict(TypedDict): r"""Number of intervals per billing cycle. Defaults to 1.""" billing_units: NotRequired[float] r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: NotRequired[float] - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" class SetupPaymentItemPrice(BaseModel): @@ -289,8 +289,8 @@ class SetupPaymentItemPrice(BaseModel): billing_units: Optional[float] = 1 r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: Optional[float] = None - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" @model_serializer(mode="wrap") def serialize_model(self, handler): @@ -304,15 +304,24 @@ class SetupPaymentItemPrice(BaseModel): "max_purchase", ] ) + nullable_fields = set(["max_purchase"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m @@ -597,8 +606,8 @@ class SetupPaymentAddItemPriceTypedDict(TypedDict): r"""Number of intervals per billing cycle. Defaults to 1.""" billing_units: NotRequired[float] r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: NotRequired[float] - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" class SetupPaymentAddItemPrice(BaseModel): @@ -624,8 +633,8 @@ class SetupPaymentAddItemPrice(BaseModel): billing_units: Optional[float] = 1 r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: Optional[float] = None - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" @model_serializer(mode="wrap") def serialize_model(self, handler): @@ -639,15 +648,24 @@ class SetupPaymentAddItemPrice(BaseModel): "max_purchase", ] ) + nullable_fields = set(["max_purchase"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m @@ -812,7 +830,20 @@ SetupPaymentRemoveItemBillingMethod = Literal[ r"""Match items with this billing method (prepaid or usage_based).""" -SetupPaymentRemoveItemInterval = Literal[ +SetupPaymentIntervalRemoveItemEnum2 = Literal[ + "one_off", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "semi_annual", + "year", +] + + +SetupPaymentIntervalRemoveItemEnum1 = Literal[ "one_off", "week", "month", @@ -820,7 +851,20 @@ SetupPaymentRemoveItemInterval = Literal[ "semi_annual", "year", ] -r"""Match items with this interval.""" + + +SetupPaymentIntervalUnionTypedDict = TypeAliasType( + "SetupPaymentIntervalUnionTypedDict", + Union[SetupPaymentIntervalRemoveItemEnum1, SetupPaymentIntervalRemoveItemEnum2], +) +r"""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.""" + + +SetupPaymentIntervalUnion = TypeAliasType( + "SetupPaymentIntervalUnion", + Union[SetupPaymentIntervalRemoveItemEnum1, SetupPaymentIntervalRemoveItemEnum2], +) +r"""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.""" class SetupPaymentPlanItemFilterTypedDict(TypedDict): @@ -830,8 +874,10 @@ class SetupPaymentPlanItemFilterTypedDict(TypedDict): r"""Match items linked to this feature.""" billing_method: NotRequired[SetupPaymentRemoveItemBillingMethod] r"""Match items with this billing method (prepaid or usage_based).""" - interval: NotRequired[SetupPaymentRemoveItemInterval] - r"""Match items with this interval.""" + interval: NotRequired[SetupPaymentIntervalUnionTypedDict] + r"""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: NotRequired[int] + r"""Match items with this interval_count. Disambiguates between items that share an interval but differ in count.""" class SetupPaymentPlanItemFilter(BaseModel): @@ -843,12 +889,17 @@ class SetupPaymentPlanItemFilter(BaseModel): billing_method: Optional[SetupPaymentRemoveItemBillingMethod] = None r"""Match items with this billing method (prepaid or usage_based).""" - interval: Optional[SetupPaymentRemoveItemInterval] = None - r"""Match items with this interval.""" + interval: Optional[SetupPaymentIntervalUnion] = None + r"""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: Optional[int] = None + r"""Match items with this interval_count. Disambiguates between items that share an interval but differ in count.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["feature_id", "billing_method", "interval"]) + optional_fields = set( + ["feature_id", "billing_method", "interval", "interval_count"] + ) serialized = handler(self) m = {} @@ -929,7 +980,7 @@ class SetupPaymentCustomizeTypedDict(TypedDict): price: NotRequired[Nullable[SetupPaymentBasePriceTypedDict]] r"""Override the base price of the plan. Pass null to remove the base price.""" items: NotRequired[List[SetupPaymentItemPlanItemTypedDict]] - r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.""" + r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.""" add_items: NotRequired[List[SetupPaymentAddItemPlanItemTypedDict]] r"""Items to add to the plan.""" remove_items: NotRequired[List[SetupPaymentPlanItemFilterTypedDict]] @@ -945,7 +996,7 @@ class SetupPaymentCustomize(BaseModel): r"""Override the base price of the plan. Pass null to remove the base price.""" items: Optional[List[SetupPaymentItemPlanItem]] = None - r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.""" + r"""Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.""" add_items: Optional[List[SetupPaymentAddItemPlanItem]] = None r"""Items to add to the plan.""" diff --git a/others/python-sdk/src/autumn_sdk/models/trackop.py b/others/python-sdk/src/autumn_sdk/models/trackop.py index b99b67ec3..8f25bd89b 100644 --- a/others/python-sdk/src/autumn_sdk/models/trackop.py +++ b/others/python-sdk/src/autumn_sdk/models/trackop.py @@ -220,7 +220,7 @@ class TrackReset2(BaseModel): return m -class Deduction2TypedDict(TypedDict): +class TrackDeduction2TypedDict(TypedDict): balance_id: str r"""ID of the underlying balance row that was deducted from (customer_entitlement or rollover).""" feature_id: str @@ -233,7 +233,7 @@ class Deduction2TypedDict(TypedDict): r"""Amount deducted from this balance. Positive when usage was consumed, negative when credit was restored (e.g. a refund via negative track value).""" -class Deduction2(BaseModel): +class TrackDeduction2(BaseModel): balance_id: str r"""ID of the underlying balance row that was deducted from (customer_entitlement or rollover).""" @@ -279,7 +279,7 @@ class TrackResponseBody2TypedDict(TypedDict): r"""The event name that was tracked, if event_name was used instead of feature_id.""" balances: NotRequired[Dict[str, Nullable[BalanceTypedDict]]] r"""Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature.""" - deductions: NotRequired[List[Deduction2TypedDict]] + deductions: NotRequired[List[TrackDeduction2TypedDict]] r"""Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling.""" @@ -304,7 +304,7 @@ class TrackResponseBody2(BaseModel): balances: Optional[Dict[str, Nullable[Balance]]] = None r"""Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature.""" - deductions: Optional[List[Deduction2]] = None + deductions: Optional[List[TrackDeduction2]] = None r"""Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling.""" @model_serializer(mode="wrap") @@ -406,7 +406,7 @@ class TrackReset1(BaseModel): return m -class Deduction1TypedDict(TypedDict): +class TrackDeduction1TypedDict(TypedDict): balance_id: str r"""ID of the underlying balance row that was deducted from (customer_entitlement or rollover).""" feature_id: str @@ -419,7 +419,7 @@ class Deduction1TypedDict(TypedDict): r"""Amount deducted from this balance. Positive when usage was consumed, negative when credit was restored (e.g. a refund via negative track value).""" -class Deduction1(BaseModel): +class TrackDeduction1(BaseModel): balance_id: str r"""ID of the underlying balance row that was deducted from (customer_entitlement or rollover).""" @@ -465,7 +465,7 @@ class TrackResponseBody1TypedDict(TypedDict): r"""The event name that was tracked, if event_name was used instead of feature_id.""" balances: NotRequired[Dict[str, Nullable[BalanceTypedDict]]] r"""Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature.""" - deductions: NotRequired[List[Deduction1TypedDict]] + deductions: NotRequired[List[TrackDeduction1TypedDict]] r"""Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling.""" @@ -490,7 +490,7 @@ class TrackResponseBody1(BaseModel): balances: Optional[Dict[str, Nullable[Balance]]] = None r"""Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature.""" - deductions: Optional[List[Deduction1]] = None + deductions: Optional[List[TrackDeduction1]] = None r"""Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling.""" @model_serializer(mode="wrap") diff --git a/others/python-sdk/src/autumn_sdk/models/tracktokensop.py b/others/python-sdk/src/autumn_sdk/models/tracktokensop.py new file mode 100644 index 000000000..553eb4618 --- /dev/null +++ b/others/python-sdk/src/autumn_sdk/models/tracktokensop.py @@ -0,0 +1,513 @@ +"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + +from __future__ import annotations +from .balance import Balance, BalanceTypedDict +from autumn_sdk.types import BaseModel, Nullable, UNSET_SENTINEL, UnrecognizedStr +from autumn_sdk.utils import FieldMetadata, HeaderMetadata +import pydantic +from pydantic import model_serializer +from typing import Any, Dict, List, Literal, Optional, Union +from typing_extensions import Annotated, NotRequired, TypeAliasType, TypedDict + + +class TrackTokensGlobalsTypedDict(TypedDict): + x_api_version: NotRequired[str] + + +class TrackTokensGlobals(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 + + +class TrackTokensParamsTypedDict(TypedDict): + customer_id: str + r"""The ID of the customer.""" + model_id: str + r"""The AI model as '/' (e.g. 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). The provider is the first path segment and must match a provider + model key in models.dev.""" + input_tokens: int + r"""Number of non-cached text input tokens consumed. Exclusive of cache and audio token pools.""" + output_tokens: int + r"""Number of text output tokens consumed. Exclusive of the reasoning and audio output pools.""" + entity_id: NotRequired[str] + r"""The ID of the entity for entity-scoped balances.""" + feature_id: NotRequired[str] + r"""The ID of the AI credit system feature. Auto-detected from the customer's entitlements if omitted — only required when a customer has multiple AI credit systems.""" + cache_read_tokens: NotRequired[int] + r"""Number of cached input tokens read.""" + cache_write_tokens: NotRequired[int] + r"""Number of input tokens written to the cache.""" + audio_input_tokens: NotRequired[int] + r"""Number of audio input tokens consumed.""" + audio_output_tokens: NotRequired[int] + r"""Number of audio output tokens generated.""" + reasoning_tokens: NotRequired[int] + r"""Number of reasoning tokens generated.""" + properties: NotRequired[Dict[str, Any]] + r"""Additional properties to attach to this usage event.""" + + +class TrackTokensParams(BaseModel): + customer_id: str + r"""The ID of the customer.""" + + model_id: str + r"""The AI model as '/' (e.g. 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). The provider is the first path segment and must match a provider + model key in models.dev.""" + + input_tokens: int + r"""Number of non-cached text input tokens consumed. Exclusive of cache and audio token pools.""" + + output_tokens: int + r"""Number of text output tokens consumed. Exclusive of the reasoning and audio output pools.""" + + entity_id: Optional[str] = None + r"""The ID of the entity for entity-scoped balances.""" + + feature_id: Optional[str] = None + r"""The ID of the AI credit system feature. Auto-detected from the customer's entitlements if omitted — only required when a customer has multiple AI credit systems.""" + + cache_read_tokens: Optional[int] = None + r"""Number of cached input tokens read.""" + + cache_write_tokens: Optional[int] = None + r"""Number of input tokens written to the cache.""" + + audio_input_tokens: Optional[int] = None + r"""Number of audio input tokens consumed.""" + + audio_output_tokens: Optional[int] = None + r"""Number of audio output tokens generated.""" + + reasoning_tokens: Optional[int] = None + r"""Number of reasoning tokens generated.""" + + properties: Optional[Dict[str, Any]] = None + r"""Additional properties to attach to this usage event.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set( + [ + "entity_id", + "feature_id", + "cache_read_tokens", + "cache_write_tokens", + "audio_input_tokens", + "audio_output_tokens", + "reasoning_tokens", + "properties", + ] + ) + 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 + + +TrackTokensIntervalEnum2 = Union[ + Literal[ + "one_off", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "semi_annual", + "year", + ], + UnrecognizedStr, +] + + +TrackTokensIntervalUnion2TypedDict = TypeAliasType( + "TrackTokensIntervalUnion2TypedDict", Union[TrackTokensIntervalEnum2, str] +) +r"""The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals.""" + + +TrackTokensIntervalUnion2 = TypeAliasType( + "TrackTokensIntervalUnion2", Union[TrackTokensIntervalEnum2, str] +) +r"""The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals.""" + + +class TrackTokensReset2TypedDict(TypedDict): + interval: TrackTokensIntervalUnion2TypedDict + r"""The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals.""" + resets_at: Nullable[float] + r"""Timestamp when the balance will next reset.""" + interval_count: NotRequired[float] + r"""Number of intervals between resets (eg. 2 for bi-monthly).""" + + +class TrackTokensReset2(BaseModel): + interval: TrackTokensIntervalUnion2 + r"""The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals.""" + + resets_at: Nullable[float] + r"""Timestamp when the balance will next reset.""" + + interval_count: Optional[float] = None + r"""Number of intervals between resets (eg. 2 for bi-monthly).""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["interval_count"]) + nullable_fields = set(["resets_at"]) + 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)) + 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 + + return m + + +class TrackTokensDeduction2TypedDict(TypedDict): + balance_id: str + r"""ID of the underlying balance row that was deducted from (customer_entitlement or rollover).""" + feature_id: str + r"""The feature this balance belongs to.""" + plan_id: Nullable[str] + r"""ID of the plan/product this balance belongs to. Null when the balance can't be attributed to a single plan (e.g. it spans multiple).""" + reset: Nullable[TrackTokensReset2TypedDict] + r"""Reset configuration for the balance this deduction came from, or null if the balance doesn't reset.""" + value: float + r"""Amount deducted from this balance. Positive when usage was consumed, negative when credit was restored (e.g. a refund via negative track value).""" + + +class TrackTokensDeduction2(BaseModel): + balance_id: str + r"""ID of the underlying balance row that was deducted from (customer_entitlement or rollover).""" + + feature_id: str + r"""The feature this balance belongs to.""" + + plan_id: Nullable[str] + r"""ID of the plan/product this balance belongs to. Null when the balance can't be attributed to a single plan (e.g. it spans multiple).""" + + reset: Nullable[TrackTokensReset2] + r"""Reset configuration for the balance this deduction came from, or null if the balance doesn't reset.""" + + value: float + r"""Amount deducted from this balance. Positive when usage was consumed, negative when credit was restored (e.g. a refund via negative track value).""" + + @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 + + +class TrackTokensResponseBody2TypedDict(TypedDict): + r"""Accepted. Autumn is experiencing degraded service from a downstream provider, so the token usage event was accepted for replay and will be tracked as soon as the service is restored.""" + + customer_id: str + r"""The ID of the customer whose usage was tracked.""" + value: float + r"""The amount of usage that was recorded.""" + balance: Nullable[BalanceTypedDict] + r"""The updated balance for the tracked feature. Null if tracking by event_name that affects multiple features.""" + entity_id: NotRequired[str] + r"""The ID of the entity, if entity-scoped tracking was performed.""" + event_name: NotRequired[str] + r"""The event name that was tracked, if event_name was used instead of feature_id.""" + balances: NotRequired[Dict[str, Nullable[BalanceTypedDict]]] + r"""Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature.""" + deductions: NotRequired[List[TrackTokensDeduction2TypedDict]] + r"""Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling.""" + + +class TrackTokensResponseBody2(BaseModel): + r"""Accepted. Autumn is experiencing degraded service from a downstream provider, so the token usage event was accepted for replay and will be tracked as soon as the service is restored.""" + + customer_id: str + r"""The ID of the customer whose usage was tracked.""" + + value: float + r"""The amount of usage that was recorded.""" + + balance: Nullable[Balance] + r"""The updated balance for the tracked feature. Null if tracking by event_name that affects multiple features.""" + + entity_id: Optional[str] = None + r"""The ID of the entity, if entity-scoped tracking was performed.""" + + event_name: Optional[str] = None + r"""The event name that was tracked, if event_name was used instead of feature_id.""" + + balances: Optional[Dict[str, Nullable[Balance]]] = None + r"""Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature.""" + + deductions: Optional[List[TrackTokensDeduction2]] = None + r"""Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["entity_id", "event_name", "balances", "deductions"]) + nullable_fields = set(["balance"]) + 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)) + 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 + + return m + + +TrackTokensIntervalEnum1 = Union[ + Literal[ + "one_off", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "semi_annual", + "year", + ], + UnrecognizedStr, +] + + +TrackTokensIntervalUnion1TypedDict = TypeAliasType( + "TrackTokensIntervalUnion1TypedDict", Union[TrackTokensIntervalEnum1, str] +) +r"""The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals.""" + + +TrackTokensIntervalUnion1 = TypeAliasType( + "TrackTokensIntervalUnion1", Union[TrackTokensIntervalEnum1, str] +) +r"""The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals.""" + + +class TrackTokensReset1TypedDict(TypedDict): + interval: TrackTokensIntervalUnion1TypedDict + r"""The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals.""" + resets_at: Nullable[float] + r"""Timestamp when the balance will next reset.""" + interval_count: NotRequired[float] + r"""Number of intervals between resets (eg. 2 for bi-monthly).""" + + +class TrackTokensReset1(BaseModel): + interval: TrackTokensIntervalUnion1 + r"""The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals.""" + + resets_at: Nullable[float] + r"""Timestamp when the balance will next reset.""" + + interval_count: Optional[float] = None + r"""Number of intervals between resets (eg. 2 for bi-monthly).""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["interval_count"]) + nullable_fields = set(["resets_at"]) + 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)) + 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 + + return m + + +class TrackTokensDeduction1TypedDict(TypedDict): + balance_id: str + r"""ID of the underlying balance row that was deducted from (customer_entitlement or rollover).""" + feature_id: str + r"""The feature this balance belongs to.""" + plan_id: Nullable[str] + r"""ID of the plan/product this balance belongs to. Null when the balance can't be attributed to a single plan (e.g. it spans multiple).""" + reset: Nullable[TrackTokensReset1TypedDict] + r"""Reset configuration for the balance this deduction came from, or null if the balance doesn't reset.""" + value: float + r"""Amount deducted from this balance. Positive when usage was consumed, negative when credit was restored (e.g. a refund via negative track value).""" + + +class TrackTokensDeduction1(BaseModel): + balance_id: str + r"""ID of the underlying balance row that was deducted from (customer_entitlement or rollover).""" + + feature_id: str + r"""The feature this balance belongs to.""" + + plan_id: Nullable[str] + r"""ID of the plan/product this balance belongs to. Null when the balance can't be attributed to a single plan (e.g. it spans multiple).""" + + reset: Nullable[TrackTokensReset1] + r"""Reset configuration for the balance this deduction came from, or null if the balance doesn't reset.""" + + value: float + r"""Amount deducted from this balance. Positive when usage was consumed, negative when credit was restored (e.g. a refund via negative track value).""" + + @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 + + +class TrackTokensResponseBody1TypedDict(TypedDict): + r"""OK""" + + customer_id: str + r"""The ID of the customer whose usage was tracked.""" + value: float + r"""The amount of usage that was recorded.""" + balance: Nullable[BalanceTypedDict] + r"""The updated balance for the tracked feature. Null if tracking by event_name that affects multiple features.""" + entity_id: NotRequired[str] + r"""The ID of the entity, if entity-scoped tracking was performed.""" + event_name: NotRequired[str] + r"""The event name that was tracked, if event_name was used instead of feature_id.""" + balances: NotRequired[Dict[str, Nullable[BalanceTypedDict]]] + r"""Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature.""" + deductions: NotRequired[List[TrackTokensDeduction1TypedDict]] + r"""Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling.""" + + +class TrackTokensResponseBody1(BaseModel): + r"""OK""" + + customer_id: str + r"""The ID of the customer whose usage was tracked.""" + + value: float + r"""The amount of usage that was recorded.""" + + balance: Nullable[Balance] + r"""The updated balance for the tracked feature. Null if tracking by event_name that affects multiple features.""" + + entity_id: Optional[str] = None + r"""The ID of the entity, if entity-scoped tracking was performed.""" + + event_name: Optional[str] = None + r"""The event name that was tracked, if event_name was used instead of feature_id.""" + + balances: Optional[Dict[str, Nullable[Balance]]] = None + r"""Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature.""" + + deductions: Optional[List[TrackTokensDeduction1]] = None + r"""Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["entity_id", "event_name", "balances", "deductions"]) + nullable_fields = set(["balance"]) + 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)) + 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 + + return m + + +TrackTokensResponseTypedDict = TypeAliasType( + "TrackTokensResponseTypedDict", + Union[TrackTokensResponseBody1TypedDict, TrackTokensResponseBody2TypedDict], +) + + +TrackTokensResponse = TypeAliasType( + "TrackTokensResponse", Union[TrackTokensResponseBody1, TrackTokensResponseBody2] +) diff --git a/others/python-sdk/src/autumn_sdk/models/updatecustomerop.py b/others/python-sdk/src/autumn_sdk/models/updatecustomerop.py index 0ab9bc333..510c7ff8b 100644 --- a/others/python-sdk/src/autumn_sdk/models/updatecustomerop.py +++ b/others/python-sdk/src/autumn_sdk/models/updatecustomerop.py @@ -46,7 +46,7 @@ class UpdateCustomerGlobals(BaseModel): return m -UpdateCustomerIntervalRequest = Literal[ +UpdateCustomerIntervalRequestBody = Literal[ "hour", "day", "week", @@ -58,7 +58,7 @@ r"""The time interval for the purchase limit window.""" class UpdateCustomerPurchaseLimitRequestTypedDict(TypedDict): r"""Optional rate limit to cap how often auto top-ups occur.""" - interval: UpdateCustomerIntervalRequest + interval: UpdateCustomerIntervalRequestBody r"""The time interval for the purchase limit window.""" limit: float r"""Maximum number of auto top-ups allowed within the interval.""" @@ -69,7 +69,7 @@ class UpdateCustomerPurchaseLimitRequestTypedDict(TypedDict): class UpdateCustomerPurchaseLimitRequest(BaseModel): r"""Optional rate limit to cap how often auto top-ups occur.""" - interval: UpdateCustomerIntervalRequest + interval: UpdateCustomerIntervalRequestBody r"""The time interval for the purchase limit window.""" limit: float @@ -994,10 +994,11 @@ UpdateCustomerType = Union[ "boolean", "metered", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] -r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" +r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" class UpdateCustomerCreditSchemaTypedDict(TypedDict): @@ -1015,6 +1016,44 @@ class UpdateCustomerCreditSchema(BaseModel): r"""Credits consumed per unit of the metered feature.""" +class UpdateCustomerModelMarkupsTypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class UpdateCustomerModelMarkups(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 UpdateCustomerProviderMarkupsTypedDict(TypedDict): + markup: float + + +class UpdateCustomerProviderMarkups(BaseModel): + markup: float + + class UpdateCustomerDisplayTypedDict(TypedDict): r"""Display names for the feature in billing UI and customer-facing components.""" @@ -1067,7 +1106,7 @@ class UpdateCustomerFeatureTypedDict(TypedDict): name: str r"""Human-readable name displayed in the dashboard and billing UI.""" type: UpdateCustomerType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" archived: bool @@ -1076,6 +1115,14 @@ class UpdateCustomerFeatureTypedDict(TypedDict): r"""Event names that trigger this feature's balance. Allows multiple features to respond to a single event.""" credit_schema: NotRequired[List[UpdateCustomerCreditSchemaTypedDict]] r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: NotRequired[Nullable[Dict[str, UpdateCustomerModelMarkupsTypedDict]]] + r"""Per-model markup overrides for AI credit systems.""" + default_markup: NotRequired[float] + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + provider_markups: NotRequired[ + Nullable[Dict[str, UpdateCustomerProviderMarkupsTypedDict]] + ] + r"""Per-provider default markup percentages for AI credit systems.""" display: NotRequired[UpdateCustomerDisplayTypedDict] r"""Display names for the feature in billing UI and customer-facing components.""" @@ -1090,7 +1137,7 @@ class UpdateCustomerFeature(BaseModel): r"""Human-readable name displayed in the dashboard and billing UI.""" type: UpdateCustomerType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" @@ -1104,21 +1151,48 @@ class UpdateCustomerFeature(BaseModel): credit_schema: Optional[List[UpdateCustomerCreditSchema]] = None r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: OptionalNullable[Dict[str, UpdateCustomerModelMarkups]] = UNSET + r"""Per-model markup overrides for AI credit systems.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[Dict[str, UpdateCustomerProviderMarkups]] = UNSET + r"""Per-provider default markup percentages for AI credit systems.""" + display: Optional[UpdateCustomerDisplay] = None r"""Display names for the feature in billing UI and customer-facing components.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["event_names", "credit_schema", "display"]) + optional_fields = set( + [ + "event_names", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "display", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m diff --git a/others/python-sdk/src/autumn_sdk/models/updateentityop.py b/others/python-sdk/src/autumn_sdk/models/updateentityop.py index 5a828ceff..479bf243e 100644 --- a/others/python-sdk/src/autumn_sdk/models/updateentityop.py +++ b/others/python-sdk/src/autumn_sdk/models/updateentityop.py @@ -458,10 +458,11 @@ UpdateEntityType = Union[ "boolean", "metered", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] -r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" +r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" class UpdateEntityCreditSchemaTypedDict(TypedDict): @@ -479,6 +480,44 @@ class UpdateEntityCreditSchema(BaseModel): r"""Credits consumed per unit of the metered feature.""" +class UpdateEntityModelMarkupsTypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class UpdateEntityModelMarkups(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 UpdateEntityProviderMarkupsTypedDict(TypedDict): + markup: float + + +class UpdateEntityProviderMarkups(BaseModel): + markup: float + + class UpdateEntityDisplayTypedDict(TypedDict): r"""Display names for the feature in billing UI and customer-facing components.""" @@ -531,7 +570,7 @@ class UpdateEntityFeatureTypedDict(TypedDict): name: str r"""Human-readable name displayed in the dashboard and billing UI.""" type: UpdateEntityType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" archived: bool @@ -540,6 +579,14 @@ class UpdateEntityFeatureTypedDict(TypedDict): r"""Event names that trigger this feature's balance. Allows multiple features to respond to a single event.""" credit_schema: NotRequired[List[UpdateEntityCreditSchemaTypedDict]] r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: NotRequired[Nullable[Dict[str, UpdateEntityModelMarkupsTypedDict]]] + r"""Per-model markup overrides for AI credit systems.""" + default_markup: NotRequired[float] + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + provider_markups: NotRequired[ + Nullable[Dict[str, UpdateEntityProviderMarkupsTypedDict]] + ] + r"""Per-provider default markup percentages for AI credit systems.""" display: NotRequired[UpdateEntityDisplayTypedDict] r"""Display names for the feature in billing UI and customer-facing components.""" @@ -554,7 +601,7 @@ class UpdateEntityFeature(BaseModel): r"""Human-readable name displayed in the dashboard and billing UI.""" type: UpdateEntityType - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" @@ -568,21 +615,48 @@ class UpdateEntityFeature(BaseModel): credit_schema: Optional[List[UpdateEntityCreditSchema]] = None r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: OptionalNullable[Dict[str, UpdateEntityModelMarkups]] = UNSET + r"""Per-model markup overrides for AI credit systems.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[Dict[str, UpdateEntityProviderMarkups]] = UNSET + r"""Per-provider default markup percentages for AI credit systems.""" + display: Optional[UpdateEntityDisplay] = None r"""Display names for the feature in billing UI and customer-facing components.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["event_names", "credit_schema", "display"]) + optional_fields = set( + [ + "event_names", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "display", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m diff --git a/others/python-sdk/src/autumn_sdk/models/updatefeatureop.py b/others/python-sdk/src/autumn_sdk/models/updatefeatureop.py index 563058b5a..daa78399b 100644 --- a/others/python-sdk/src/autumn_sdk/models/updatefeatureop.py +++ b/others/python-sdk/src/autumn_sdk/models/updatefeatureop.py @@ -12,7 +12,7 @@ from autumn_sdk.types import ( from autumn_sdk.utils import FieldMetadata, HeaderMetadata import pydantic from pydantic import model_serializer -from typing import List, Literal, Optional, Union +from typing import Dict, List, Literal, Optional, Union from typing_extensions import Annotated, NotRequired, TypedDict @@ -44,22 +44,23 @@ class UpdateFeatureGlobals(BaseModel): return m -UpdateFeatureTypeRequest = Literal[ +UpdateFeatureTypeRequestBody = Literal[ "boolean", "metered", "credit_system", + "ai_credit_system", ] r"""The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system.""" -class UpdateFeatureDisplayRequestTypedDict(TypedDict): +class UpdateFeatureDisplayRequestBodyTypedDict(TypedDict): r"""Singular and plural display names for the feature in your user interface.""" singular: str plural: str -class UpdateFeatureDisplayRequest(BaseModel): +class UpdateFeatureDisplayRequestBody(BaseModel): r"""Singular and plural display names for the feature in your user interface.""" singular: str @@ -67,30 +68,78 @@ class UpdateFeatureDisplayRequest(BaseModel): plural: str -class UpdateFeatureCreditSchemaRequestTypedDict(TypedDict): +class UpdateFeatureCreditSchemaRequestBodyTypedDict(TypedDict): metered_feature_id: str credit_cost: float -class UpdateFeatureCreditSchemaRequest(BaseModel): +class UpdateFeatureCreditSchemaRequestBody(BaseModel): metered_feature_id: str credit_cost: float +class UpdateFeatureModelMarkupsRequestTypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class UpdateFeatureModelMarkupsRequest(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 UpdateFeatureProviderMarkupsRequestTypedDict(TypedDict): + markup: float + + +class UpdateFeatureProviderMarkupsRequest(BaseModel): + markup: float + + class UpdateFeatureParamsTypedDict(TypedDict): feature_id: str r"""The ID of the feature to update.""" name: NotRequired[str] r"""The name of the feature.""" - type: NotRequired[UpdateFeatureTypeRequest] + type: NotRequired[UpdateFeatureTypeRequestBody] r"""The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system.""" consumable: NotRequired[bool] r"""Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features.""" - display: NotRequired[UpdateFeatureDisplayRequestTypedDict] + display: NotRequired[UpdateFeatureDisplayRequestBodyTypedDict] r"""Singular and plural display names for the feature in your user interface.""" - credit_schema: NotRequired[List[UpdateFeatureCreditSchemaRequestTypedDict]] - r"""A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features.""" + credit_schema: NotRequired[List[UpdateFeatureCreditSchemaRequestBodyTypedDict]] + r"""A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead.""" + model_markups: NotRequired[ + Nullable[Dict[str, UpdateFeatureModelMarkupsRequestTypedDict]] + ] + r"""Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration.""" + default_markup: NotRequired[float] + r"""Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free.""" + provider_markups: NotRequired[ + Nullable[Dict[str, UpdateFeatureProviderMarkupsRequestTypedDict]] + ] + r"""Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id.""" event_names: NotRequired[List[str]] archived: NotRequired[bool] r"""Whether the feature is archived. Archived features are hidden from the dashboard.""" @@ -105,17 +154,28 @@ class UpdateFeatureParams(BaseModel): name: Optional[str] = None r"""The name of the feature.""" - type: Optional[UpdateFeatureTypeRequest] = None + type: Optional[UpdateFeatureTypeRequestBody] = None r"""The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system.""" consumable: Optional[bool] = None r"""Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features.""" - display: Optional[UpdateFeatureDisplayRequest] = None + display: Optional[UpdateFeatureDisplayRequestBody] = None r"""Singular and plural display names for the feature in your user interface.""" - credit_schema: Optional[List[UpdateFeatureCreditSchemaRequest]] = None - r"""A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features.""" + credit_schema: Optional[List[UpdateFeatureCreditSchemaRequestBody]] = None + r"""A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead.""" + + model_markups: OptionalNullable[Dict[str, UpdateFeatureModelMarkupsRequest]] = UNSET + r"""Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[ + Dict[str, UpdateFeatureProviderMarkupsRequest] + ] = UNSET + r"""Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id.""" event_names: Optional[List[str]] = None @@ -134,20 +194,32 @@ class UpdateFeatureParams(BaseModel): "consumable", "display", "credit_schema", + "model_markups", + "default_markup", + "provider_markups", "event_names", "archived", "new_feature_id", ] ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m @@ -158,10 +230,11 @@ UpdateFeatureTypeResponse = Union[ "boolean", "metered", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] -r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" +r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" class UpdateFeatureCreditSchemaResponseTypedDict(TypedDict): @@ -179,6 +252,44 @@ class UpdateFeatureCreditSchemaResponse(BaseModel): r"""Credits consumed per unit of the metered feature.""" +class UpdateFeatureModelMarkupsResponseTypedDict(TypedDict): + markup: NotRequired[float] + input_cost: NotRequired[float] + output_cost: NotRequired[float] + + +class UpdateFeatureModelMarkupsResponse(BaseModel): + markup: Optional[float] = None + + input_cost: Optional[float] = None + + output_cost: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["markup", "input_cost", "output_cost"]) + 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 UpdateFeatureProviderMarkupsResponseTypedDict(TypedDict): + markup: float + + +class UpdateFeatureProviderMarkupsResponse(BaseModel): + markup: float + + class UpdateFeatureDisplayResponseTypedDict(TypedDict): r"""Display names for the feature in billing UI and customer-facing components.""" @@ -231,7 +342,7 @@ class UpdateFeatureResponseTypedDict(TypedDict): name: str r"""Human-readable name displayed in the dashboard and billing UI.""" type: UpdateFeatureTypeResponse - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" archived: bool @@ -240,6 +351,16 @@ class UpdateFeatureResponseTypedDict(TypedDict): r"""Event names that trigger this feature's balance. Allows multiple features to respond to a single event.""" credit_schema: NotRequired[List[UpdateFeatureCreditSchemaResponseTypedDict]] r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: NotRequired[ + Nullable[Dict[str, UpdateFeatureModelMarkupsResponseTypedDict]] + ] + r"""Per-model markup overrides for AI credit systems.""" + default_markup: NotRequired[float] + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + provider_markups: NotRequired[ + Nullable[Dict[str, UpdateFeatureProviderMarkupsResponseTypedDict]] + ] + r"""Per-provider default markup percentages for AI credit systems.""" display: NotRequired[UpdateFeatureDisplayResponseTypedDict] r"""Display names for the feature in billing UI and customer-facing components.""" @@ -254,7 +375,7 @@ class UpdateFeatureResponse(BaseModel): r"""Human-readable name displayed in the dashboard and billing UI.""" type: UpdateFeatureTypeResponse - r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.""" + r"""Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.""" consumable: bool r"""For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).""" @@ -268,21 +389,52 @@ class UpdateFeatureResponse(BaseModel): credit_schema: Optional[List[UpdateFeatureCreditSchemaResponse]] = None r"""For credit_system features: maps metered features to their credit costs.""" + model_markups: OptionalNullable[Dict[str, UpdateFeatureModelMarkupsResponse]] = ( + UNSET + ) + r"""Per-model markup overrides for AI credit systems.""" + + default_markup: Optional[float] = None + r"""Default percentage markup for AI credit systems. Use -100 to make usage free.""" + + provider_markups: OptionalNullable[ + Dict[str, UpdateFeatureProviderMarkupsResponse] + ] = UNSET + r"""Per-provider default markup percentages for AI credit systems.""" + display: Optional[UpdateFeatureDisplayResponse] = None r"""Display names for the feature in billing UI and customer-facing components.""" @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["event_names", "credit_schema", "display"]) + optional_fields = set( + [ + "event_names", + "credit_schema", + "model_markups", + "default_markup", + "provider_markups", + "display", + ] + ) + nullable_fields = set(["model_markups", "provider_markups"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m diff --git a/others/python-sdk/src/autumn_sdk/models/updateplanop.py b/others/python-sdk/src/autumn_sdk/models/updateplanop.py index 9a4531386..9283737f1 100644 --- a/others/python-sdk/src/autumn_sdk/models/updateplanop.py +++ b/others/python-sdk/src/autumn_sdk/models/updateplanop.py @@ -12,7 +12,7 @@ from autumn_sdk.types import ( from autumn_sdk.utils import FieldMetadata, HeaderMetadata import pydantic from pydantic import model_serializer -from typing import Any, List, Literal, Optional, Union +from typing import List, Literal, Optional, Union from typing_extensions import Annotated, NotRequired, TypeAliasType, TypedDict @@ -44,7 +44,7 @@ class UpdatePlanGlobals(BaseModel): return m -UpdatePlanPriceIntervalRequest = Literal[ +UpdatePlanPriceIntervalRequestBody = Literal[ "one_off", "week", "month", @@ -60,7 +60,7 @@ class UpdatePlanBasePriceTypedDict(TypedDict): amount: float r"""Base price amount for the plan.""" - interval: UpdatePlanPriceIntervalRequest + interval: UpdatePlanPriceIntervalRequestBody r"""Billing interval (e.g. 'month', 'year').""" interval_count: NotRequired[float] r"""Number of intervals per billing cycle. Defaults to 1.""" @@ -72,7 +72,7 @@ class UpdatePlanBasePrice(BaseModel): amount: float r"""Base price amount for the plan.""" - interval: UpdatePlanPriceIntervalRequest + interval: UpdatePlanPriceIntervalRequestBody r"""Billing interval (e.g. 'month', 'year').""" interval_count: Optional[float] = None @@ -95,7 +95,7 @@ class UpdatePlanBasePrice(BaseModel): return m -UpdatePlanResetIntervalRequest = Literal[ +UpdatePlanResetIntervalRequestBody = Literal[ "one_off", "minute", "hour", @@ -109,19 +109,19 @@ UpdatePlanResetIntervalRequest = Literal[ r"""Interval at which balance resets (e.g. 'month', 'year'). For consumable features only.""" -class UpdatePlanResetRequestTypedDict(TypedDict): +class UpdatePlanResetRequestBodyTypedDict(TypedDict): r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" - interval: UpdatePlanResetIntervalRequest + interval: UpdatePlanResetIntervalRequestBody r"""Interval at which balance resets (e.g. 'month', 'year'). For consumable features only.""" interval_count: NotRequired[float] r"""Number of intervals between resets. Defaults to 1.""" -class UpdatePlanResetRequest(BaseModel): +class UpdatePlanResetRequestBody(BaseModel): r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" - interval: UpdatePlanResetIntervalRequest + interval: UpdatePlanResetIntervalRequestBody r"""Interval at which balance resets (e.g. 'month', 'year'). For consumable features only.""" interval_count: Optional[float] = None @@ -144,20 +144,22 @@ class UpdatePlanResetRequest(BaseModel): return m -UpdatePlanToTypedDict = TypeAliasType("UpdatePlanToTypedDict", Union[float, str]) +UpdatePlanToRequestBodyTypedDict = TypeAliasType( + "UpdatePlanToRequestBodyTypedDict", Union[float, str] +) -UpdatePlanTo = TypeAliasType("UpdatePlanTo", Union[float, str]) +UpdatePlanToRequestBody = TypeAliasType("UpdatePlanToRequestBody", Union[float, str]) -class UpdatePlanTierTypedDict(TypedDict): - to: UpdatePlanToTypedDict +class UpdatePlanTierRequestBodyTypedDict(TypedDict): + to: UpdatePlanToRequestBodyTypedDict amount: NotRequired[float] flat_amount: NotRequired[float] -class UpdatePlanTier(BaseModel): - to: UpdatePlanTo +class UpdatePlanTierRequestBody(BaseModel): + to: UpdatePlanToRequestBody amount: Optional[float] = None @@ -180,13 +182,13 @@ class UpdatePlanTier(BaseModel): return m -UpdatePlanTierBehaviorRequest = Literal[ +UpdatePlanTierBehaviorRequestBody = Literal[ "graduated", "volume", ] -UpdatePlanItemPriceIntervalRequest = Literal[ +UpdatePlanItemPriceIntervalRequestBody = Literal[ "one_off", "week", "month", @@ -197,49 +199,49 @@ UpdatePlanItemPriceIntervalRequest = Literal[ r"""Billing interval. For consumable features, should match reset.interval.""" -UpdatePlanBillingMethodRequest = Literal[ +UpdatePlanBillingMethodRequestBody = Literal[ "prepaid", "usage_based", ] r"""'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go.""" -class UpdatePlanPriceRequestTypedDict(TypedDict): +class UpdatePlanPriceRequestBodyTypedDict(TypedDict): r"""Pricing for usage beyond included units. Omit for free features.""" - interval: UpdatePlanItemPriceIntervalRequest + interval: UpdatePlanItemPriceIntervalRequestBody r"""Billing interval. For consumable features, should match reset.interval.""" - billing_method: UpdatePlanBillingMethodRequest + billing_method: UpdatePlanBillingMethodRequestBody r"""'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go.""" amount: NotRequired[float] r"""Price per billing_units after included usage. Either 'amount' or 'tiers' is required.""" - tiers: NotRequired[List[UpdatePlanTierTypedDict]] + tiers: NotRequired[List[UpdatePlanTierRequestBodyTypedDict]] r"""Tiered pricing. Either 'amount' or 'tiers' is required.""" - tier_behavior: NotRequired[UpdatePlanTierBehaviorRequest] + tier_behavior: NotRequired[UpdatePlanTierBehaviorRequestBody] interval_count: NotRequired[float] r"""Number of intervals per billing cycle. Defaults to 1.""" billing_units: NotRequired[float] r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: NotRequired[float] - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: NotRequired[Nullable[float]] + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" -class UpdatePlanPriceRequest(BaseModel): +class UpdatePlanPriceRequestBody(BaseModel): r"""Pricing for usage beyond included units. Omit for free features.""" - interval: UpdatePlanItemPriceIntervalRequest + interval: UpdatePlanItemPriceIntervalRequestBody r"""Billing interval. For consumable features, should match reset.interval.""" - billing_method: UpdatePlanBillingMethodRequest + billing_method: UpdatePlanBillingMethodRequestBody r"""'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go.""" amount: Optional[float] = None r"""Price per billing_units after included usage. Either 'amount' or 'tiers' is required.""" - tiers: Optional[List[UpdatePlanTier]] = None + tiers: Optional[List[UpdatePlanTierRequestBody]] = None r"""Tiered pricing. Either 'amount' or 'tiers' is required.""" - tier_behavior: Optional[UpdatePlanTierBehaviorRequest] = None + tier_behavior: Optional[UpdatePlanTierBehaviorRequestBody] = None interval_count: Optional[float] = 1 r"""Number of intervals per billing cycle. Defaults to 1.""" @@ -247,8 +249,8 @@ class UpdatePlanPriceRequest(BaseModel): billing_units: Optional[float] = 1 r"""Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200).""" - max_purchase: Optional[float] = None - r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.""" + max_purchase: OptionalNullable[float] = UNSET + r"""Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.""" @model_serializer(mode="wrap") def serialize_model(self, handler): @@ -262,15 +264,24 @@ class UpdatePlanPriceRequest(BaseModel): "max_purchase", ] ) + nullable_fields = set(["max_purchase"]) 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)) + 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: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): m[k] = val return m @@ -314,17 +325,17 @@ class UpdatePlanProration(BaseModel): r"""Credit behavior when quantity decreases mid-cycle.""" -UpdatePlanExpiryDurationTypeRequest = Literal[ +UpdatePlanExpiryDurationTypeRequestBody = Literal[ "month", "forever", ] r"""When rolled over units expire.""" -class UpdatePlanRolloverRequestTypedDict(TypedDict): +class UpdatePlanRolloverRequestBodyTypedDict(TypedDict): r"""Rollover config for unused units. If set, unused included units carry over.""" - expiry_duration_type: UpdatePlanExpiryDurationTypeRequest + expiry_duration_type: UpdatePlanExpiryDurationTypeRequestBody r"""When rolled over units expire.""" max: NotRequired[float] r"""Max rollover units. Omit for unlimited rollover.""" @@ -334,10 +345,10 @@ class UpdatePlanRolloverRequestTypedDict(TypedDict): r"""Number of periods before expiry.""" -class UpdatePlanRolloverRequest(BaseModel): +class UpdatePlanRolloverRequestBody(BaseModel): r"""Rollover config for unused units. If set, unused included units carry over.""" - expiry_duration_type: UpdatePlanExpiryDurationTypeRequest + expiry_duration_type: UpdatePlanExpiryDurationTypeRequestBody r"""When rolled over units expire.""" max: Optional[float] = None @@ -375,13 +386,13 @@ class UpdatePlanPlanItemTypedDict(TypedDict): r"""Number of free units included. Balance resets to this each interval for consumable features.""" unlimited: NotRequired[bool] r"""If true, customer has unlimited access to this feature.""" - reset: NotRequired[UpdatePlanResetRequestTypedDict] + reset: NotRequired[UpdatePlanResetRequestBodyTypedDict] r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" - price: NotRequired[UpdatePlanPriceRequestTypedDict] + price: NotRequired[UpdatePlanPriceRequestBodyTypedDict] r"""Pricing for usage beyond included units. Omit for free features.""" proration: NotRequired[UpdatePlanProrationTypedDict] r"""Proration settings for prepaid features. Controls mid-cycle quantity change billing.""" - rollover: NotRequired[UpdatePlanRolloverRequestTypedDict] + rollover: NotRequired[UpdatePlanRolloverRequestBodyTypedDict] r"""Rollover config for unused units. If set, unused included units carry over.""" @@ -397,16 +408,16 @@ class UpdatePlanPlanItem(BaseModel): unlimited: Optional[bool] = None r"""If true, customer has unlimited access to this feature.""" - reset: Optional[UpdatePlanResetRequest] = None + reset: Optional[UpdatePlanResetRequestBody] = None r"""Reset configuration for consumable features. Omit for non-consumable features like seats.""" - price: Optional[UpdatePlanPriceRequest] = None + price: Optional[UpdatePlanPriceRequestBody] = None r"""Pricing for usage beyond included units. Omit for free features.""" proration: Optional[UpdatePlanProration] = None r"""Proration settings for prepaid features. Controls mid-cycle quantity change billing.""" - rollover: Optional[UpdatePlanRolloverRequest] = None + rollover: Optional[UpdatePlanRolloverRequestBody] = None r"""Rollover config for unused units. If set, unused included units carry over.""" @model_serializer(mode="wrap") @@ -543,6 +554,7 @@ class UpdatePlanParamsTypedDict(TypedDict): archived: NotRequired[bool] new_plan_id: NotRequired[str] r"""The new ID to use for the plan. Can only be updated if the plan has not been used by any customers.""" + disable_version: NotRequired[bool] class UpdatePlanParams(BaseModel): @@ -584,6 +596,8 @@ class UpdatePlanParams(BaseModel): new_plan_id: Optional[str] = None r"""The new ID to use for the plan. Can only be updated if the plan has not been used by any customers.""" + disable_version: Optional[bool] = None + @model_serializer(mode="wrap") def serialize_model(self, handler): optional_fields = set( @@ -601,6 +615,7 @@ class UpdatePlanParams(BaseModel): "version", "archived", "new_plan_id", + "disable_version", ] ) nullable_fields = set(["price", "free_trial"]) @@ -723,6 +738,7 @@ UpdatePlanType = Union[ "single_use", "continuous_use", "credit_system", + "ai_credit_system", ], UnrecognizedStr, ] @@ -871,6 +887,44 @@ class UpdatePlanResetResponse(BaseModel): return m +UpdatePlanToResponseTypedDict = TypeAliasType( + "UpdatePlanToResponseTypedDict", Union[float, str] +) + + +UpdatePlanToResponse = TypeAliasType("UpdatePlanToResponse", Union[float, str]) + + +class UpdatePlanTierResponseTypedDict(TypedDict): + to: UpdatePlanToResponseTypedDict + amount: float + flat_amount: NotRequired[float] + + +class UpdatePlanTierResponse(BaseModel): + to: UpdatePlanToResponse + + amount: float + + flat_amount: Optional[float] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["flat_amount"]) + 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 + + UpdatePlanTierBehaviorResponse = Union[ Literal[ "graduated", @@ -915,7 +969,7 @@ class UpdatePlanItemPriceResponseTypedDict(TypedDict): r"""Maximum units a customer can purchase beyond included. E.g. if included=100 and max_purchase=300, customer can use up to 400 total before usage is capped. Null for no limit.""" amount: NotRequired[float] r"""Price per billing_units after included usage is consumed. Mutually exclusive with tiers.""" - tiers: NotRequired[List[Nullable[Any]]] + tiers: NotRequired[List[UpdatePlanTierResponseTypedDict]] r"""Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.""" tier_behavior: NotRequired[UpdatePlanTierBehaviorResponse] interval_count: NotRequired[float] @@ -938,7 +992,7 @@ class UpdatePlanItemPriceResponse(BaseModel): amount: Optional[float] = None r"""Price per billing_units after included usage is consumed. Mutually exclusive with tiers.""" - tiers: Optional[List[Nullable[Any]]] = None + tiers: Optional[List[UpdatePlanTierResponse]] = None r"""Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.""" tier_behavior: Optional[UpdatePlanTierBehaviorResponse] = None diff --git a/others/python-sdk/src/autumn_sdk/plans.py b/others/python-sdk/src/autumn_sdk/plans.py index 4c299d37e..57f4485f2 100644 --- a/others/python-sdk/src/autumn_sdk/plans.py +++ b/others/python-sdk/src/autumn_sdk/plans.py @@ -19,7 +19,10 @@ class Plans(BaseSDK): add_on: Optional[bool] = False, auto_enable: Optional[bool] = False, price: Optional[ - Union[models.CreatePlanPriceRequest, models.CreatePlanPriceRequestTypedDict] + Union[ + models.CreatePlanPriceRequestBody, + models.CreatePlanPriceRequestBodyTypedDict, + ] ] = None, items: Optional[ Union[ @@ -81,7 +84,7 @@ class Plans(BaseSDK): add_on=add_on, auto_enable=auto_enable, price=utils.get_pydantic_model( - price, Optional[models.CreatePlanPriceRequest] + price, Optional[models.CreatePlanPriceRequestBody] ), items=utils.get_pydantic_model( items, Optional[List[models.CreatePlanPlanItem]] @@ -164,7 +167,10 @@ class Plans(BaseSDK): add_on: Optional[bool] = False, auto_enable: Optional[bool] = False, price: Optional[ - Union[models.CreatePlanPriceRequest, models.CreatePlanPriceRequestTypedDict] + Union[ + models.CreatePlanPriceRequestBody, + models.CreatePlanPriceRequestBodyTypedDict, + ] ] = None, items: Optional[ Union[ @@ -226,7 +232,7 @@ class Plans(BaseSDK): add_on=add_on, auto_enable=auto_enable, price=utils.get_pydantic_model( - price, Optional[models.CreatePlanPriceRequest] + price, Optional[models.CreatePlanPriceRequestBody] ), items=utils.get_pydantic_model( items, Optional[List[models.CreatePlanPlanItem]] @@ -718,6 +724,7 @@ class Plans(BaseSDK): version: Optional[float] = None, archived: Optional[bool] = False, new_plan_id: Optional[str] = None, + disable_version: Optional[bool] = None, retries: OptionalNullable[utils.RetryConfig] = UNSET, server_url: Optional[str] = None, timeout_ms: Optional[int] = None, @@ -743,6 +750,7 @@ class Plans(BaseSDK): :param version: :param archived: :param new_plan_id: The new ID to use for the plan. Can only be updated if the plan has not been used by any customers. + :param disable_version: :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 @@ -781,6 +789,7 @@ class Plans(BaseSDK): version=version, archived=archived, new_plan_id=new_plan_id, + disable_version=disable_version, ) req = self._build_request( @@ -875,6 +884,7 @@ class Plans(BaseSDK): version: Optional[float] = None, archived: Optional[bool] = False, new_plan_id: Optional[str] = None, + disable_version: Optional[bool] = None, retries: OptionalNullable[utils.RetryConfig] = UNSET, server_url: Optional[str] = None, timeout_ms: Optional[int] = None, @@ -900,6 +910,7 @@ class Plans(BaseSDK): :param version: :param archived: :param new_plan_id: The new ID to use for the plan. Can only be updated if the plan has not been used by any customers. + :param disable_version: :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 @@ -938,6 +949,7 @@ class Plans(BaseSDK): version=version, archived=archived, new_plan_id=new_plan_id, + disable_version=disable_version, ) req = self._build_request_async( diff --git a/others/python-sdk/src/autumn_sdk/sdk.py b/others/python-sdk/src/autumn_sdk/sdk.py index 8feede30c..ef844917d 100644 --- a/others/python-sdk/src/autumn_sdk/sdk.py +++ b/others/python-sdk/src/autumn_sdk/sdk.py @@ -684,6 +684,260 @@ class Autumn(BaseSDK): raise errors.AutumnDefaultError("Unexpected response received", http_res) + def track_tokens( + self, + *, + customer_id: str, + model_id: str, + input_tokens: int, + output_tokens: int, + entity_id: Optional[str] = None, + feature_id: Optional[str] = None, + cache_read_tokens: Optional[int] = None, + cache_write_tokens: Optional[int] = None, + audio_input_tokens: Optional[int] = None, + audio_output_tokens: Optional[int] = None, + reasoning_tokens: Optional[int] = None, + properties: Optional[Dict[str, Any]] = None, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.TrackTokensResponse: + r"""Records AI token usage for a customer and returns the updated AI credit balance. + + Use this after an LLM request when you have input and output token counts. Autumn converts token usage to a dollar amount using the configured model pricing and markup, then tracks that value against the customer's AI credit system. + + :param customer_id: The ID of the customer. + :param model_id: The AI model as '/' (e.g. 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). The provider is the first path segment and must match a provider + model key in models.dev. + :param input_tokens: Number of non-cached text input tokens consumed. Exclusive of cache and audio token pools. + :param output_tokens: Number of text output tokens consumed. Exclusive of the reasoning and audio output pools. + :param entity_id: The ID of the entity for entity-scoped balances. + :param feature_id: The ID of the AI credit system feature. Auto-detected from the customer's entitlements if omitted — only required when a customer has multiple AI credit systems. + :param cache_read_tokens: Number of cached input tokens read. + :param cache_write_tokens: Number of input tokens written to the cache. + :param audio_input_tokens: Number of audio input tokens consumed. + :param audio_output_tokens: Number of audio output tokens generated. + :param reasoning_tokens: Number of reasoning tokens generated. + :param properties: Additional properties to attach to this usage event. + :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.TrackTokensParams( + customer_id=customer_id, + entity_id=entity_id, + feature_id=feature_id, + model_id=model_id, + input_tokens=input_tokens, + output_tokens=output_tokens, + cache_read_tokens=cache_read_tokens, + cache_write_tokens=cache_write_tokens, + audio_input_tokens=audio_input_tokens, + audio_output_tokens=audio_output_tokens, + reasoning_tokens=reasoning_tokens, + properties=properties, + ) + + req = self._build_request( + method="POST", + path="/v1/balances.track_tokens", + 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.TrackTokensGlobals( + 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.TrackTokensParams + ), + 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="trackTokens", + 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.TrackTokensResponseBody1, http_res) + if utils.match_response(http_res, "202", "application/json"): + return unmarshal_json_response(models.TrackTokensResponseBody2, 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 track_tokens_async( + self, + *, + customer_id: str, + model_id: str, + input_tokens: int, + output_tokens: int, + entity_id: Optional[str] = None, + feature_id: Optional[str] = None, + cache_read_tokens: Optional[int] = None, + cache_write_tokens: Optional[int] = None, + audio_input_tokens: Optional[int] = None, + audio_output_tokens: Optional[int] = None, + reasoning_tokens: Optional[int] = None, + properties: Optional[Dict[str, Any]] = None, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.TrackTokensResponse: + r"""Records AI token usage for a customer and returns the updated AI credit balance. + + Use this after an LLM request when you have input and output token counts. Autumn converts token usage to a dollar amount using the configured model pricing and markup, then tracks that value against the customer's AI credit system. + + :param customer_id: The ID of the customer. + :param model_id: The AI model as '/' (e.g. 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). The provider is the first path segment and must match a provider + model key in models.dev. + :param input_tokens: Number of non-cached text input tokens consumed. Exclusive of cache and audio token pools. + :param output_tokens: Number of text output tokens consumed. Exclusive of the reasoning and audio output pools. + :param entity_id: The ID of the entity for entity-scoped balances. + :param feature_id: The ID of the AI credit system feature. Auto-detected from the customer's entitlements if omitted — only required when a customer has multiple AI credit systems. + :param cache_read_tokens: Number of cached input tokens read. + :param cache_write_tokens: Number of input tokens written to the cache. + :param audio_input_tokens: Number of audio input tokens consumed. + :param audio_output_tokens: Number of audio output tokens generated. + :param reasoning_tokens: Number of reasoning tokens generated. + :param properties: Additional properties to attach to this usage event. + :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.TrackTokensParams( + customer_id=customer_id, + entity_id=entity_id, + feature_id=feature_id, + model_id=model_id, + input_tokens=input_tokens, + output_tokens=output_tokens, + cache_read_tokens=cache_read_tokens, + cache_write_tokens=cache_write_tokens, + audio_input_tokens=audio_input_tokens, + audio_output_tokens=audio_output_tokens, + reasoning_tokens=reasoning_tokens, + properties=properties, + ) + + req = self._build_request_async( + method="POST", + path="/v1/balances.track_tokens", + 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.TrackTokensGlobals( + 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.TrackTokensParams + ), + 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="trackTokens", + 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.TrackTokensResponseBody1, http_res) + if utils.match_response(http_res, "202", "application/json"): + return unmarshal_json_response(models.TrackTokensResponseBody2, 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 batch_track( self, *, diff --git a/package.json b/package.json index 7be9cc689..3c0d30733 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "packages/autumn-js", "packages/openapi", "packages/ksuid", - "packages/stripe-sync" + "packages/stripe-sync", + "packages/ai-sdk" ], "catalog": { "stripe": "19.3.0-beta.1", @@ -50,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", @@ -140,7 +139,7 @@ "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=@autumn/auth --filter=@autumn/mcp --filter=@autumn/leaf", + "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/bun.lock b/packages/ai-sdk/bun.lock new file mode 100644 index 000000000..34f668488 --- /dev/null +++ b/packages/ai-sdk/bun.lock @@ -0,0 +1,253 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@useautumn/ai-sdk", + "devDependencies": { + "@types/node": "^24.9.1", + "tsup": "^8.4.0", + "typescript": "^5.8.3", + }, + "peerDependencies": { + "ai": "^6.0.116", + "autumn-js": "*", + }, + }, + }, + "packages": { + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.66", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@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=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + + "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "ai": ["ai@6.0.116", "", { "dependencies": { "@ai-sdk/gateway": "3.0.66", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "autumn-js": ["autumn-js@1.0.5", "", { "dependencies": { "query-string": "^9.2.2", "rou3": "^0.6.1", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": "^1.3.17", "better-call": "^1.0.12", "express": "^5.2.1", "hono": "^4.0.0", "next": "^14.0.0 || ^15.0.0", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["better-auth", "better-call", "express", "hono", "next", "react"] }, "sha512-Xh4hqx7EO+hilVjCuilLjUt5iw6RP8vxKmkKmwwHM8WhrHH4StAol17Qis9FtZ/ixeHfe4TOFojzgLhP74kETw=="], + + "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-uri-component": ["decode-uri-component@0.4.1", "", {}, "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ=="], + + "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "filter-obj": ["filter-obj@5.1.0", "", {}, "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng=="], + + "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=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mlly": ["mlly@1.8.1", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "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=="], + + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + + "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=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + + "rou3": ["rou3@0.6.3", "", {}, "sha512-1HSG1ENTj7Kkm5muMnXuzzfdDOf7CFnbSYFA+H3Fp/rB9lOCxCPgy1jlZxTKyFoC5jJay8Mmc+VbPLYRjzYLrA=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "split-on-first": ["split-on-first@3.0.0", "", {}, "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA=="], + + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + + "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=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + + "tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + } +} diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json new file mode 100644 index 000000000..6907b1fb9 --- /dev/null +++ b/packages/ai-sdk/package.json @@ -0,0 +1,39 @@ +{ + "name": "@useautumn/ai-sdk", + "version": "0.0.1", + "type": "module", + "description": "AI SDK for Autumn", + "main": "./dist/sdk/index.cjs", + "module": "./dist/sdk/index.js", + "types": "./dist/sdk/index.d.ts", + "files": [ + "dist", + "README.md", + "LICENSE.md" + ], + "exports": { + ".": { + "types": "./dist/sdk/index.d.ts", + "require": "./dist/sdk/index.cjs", + "import": "./dist/sdk/index.js" + } + }, + "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": "*" + }, + "devDependencies": { + "@types/node": "^24.9.1", + "tsup": "^8.4.0", + "typescript": "^5.8.3" + } +} diff --git a/packages/ai-sdk/src/index.ts b/packages/ai-sdk/src/index.ts new file mode 100644 index 000000000..e786c48be --- /dev/null +++ b/packages/ai-sdk/src/index.ts @@ -0,0 +1,107 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider"; +import { type LanguageModelMiddleware, wrapLanguageModel } from "ai"; +import { normalizeUsage, type TokenPools, type UsageLike } from "./usage.js"; + +export type { TokenPools, UsageLike } from "./usage.js"; + +type TrackTokensParams = TokenPools & { + customerId: string; + modelId: string; + featureId?: string; + entityId?: string; + properties?: Record; +}; + +/** Structural view of the autumn-js client; older versions may not ship balances.trackTokens. */ +export type AutumnClient = { + balances?: { + trackTokens?: (params: TrackTokensParams) => Promise; + }; +}; + +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 = ({ + autumn, + model, + customerId, + providerId, + featureId, + entityId, + properties, +}: WithAutumnOptions): LanguageModelV3 => { + const modelName = `${providerId ?? model.provider}/${model.modelId}`; + + const trackUsage = async (usage: UsageLike) => { + try { + 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, + featureId, + entityId, + properties, + }); + } catch (error) { + console.error("[Autumn Tracking] Failed to track usage:", error); + } + }; + + const middleware: LanguageModelMiddleware = { + specificationVersion: "v3", + wrapGenerate: async ({ doGenerate }) => { + const result = await doGenerate(); + await trackUsage(result.usage as UsageLike); + return result; + }, + wrapStream: async ({ doStream }) => { + const { stream, ...rest } = await doStream(); + + let trackingPromise: Promise | undefined; + + type StreamChunk = typeof stream extends ReadableStream + ? T + : never; + + const transformStream = new TransformStream({ + transform(chunk, controller) { + if (chunk.type === "finish" && chunk.usage) { + trackingPromise = trackUsage(chunk.usage as UsageLike); + } + controller.enqueue(chunk); + }, + async flush() { + await trackingPromise; + }, + }); + + return { + stream: stream.pipeThrough(transformStream), + ...rest, + }; + }, + }; + + 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/ai-sdk/tsconfig.json b/packages/ai-sdk/tsconfig.json new file mode 100644 index 000000000..c3ea081e4 --- /dev/null +++ b/packages/ai-sdk/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "esModuleInterop": true, + "strictNullChecks": true, + "target": "ES2022", + "moduleResolution": "NodeNext", + "module": "NodeNext", + "declaration": true, + "isolatedModules": true, + "noEmit": true, + "outDir": "dist", + "lib": ["ES2022"], + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/ai-sdk/tsup.config.ts b/packages/ai-sdk/tsup.config.ts new file mode 100644 index 000000000..5ea218dce --- /dev/null +++ b/packages/ai-sdk/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + "sdk/index": "src/index.ts", + }, + format: ["cjs", "esm"], + dts: true, + splitting: false, + sourcemap: false, + clean: true, +}); 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/compose/models/featureModels.ts b/packages/atmn/src/compose/models/featureModels.ts index 5a2e63bdb..826059b0d 100644 --- a/packages/atmn/src/compose/models/featureModels.ts +++ b/packages/atmn/src/compose/models/featureModels.ts @@ -4,92 +4,108 @@ import { z } from "zod/v4"; - export const FeatureSchema = z.object({ - id: z.string().meta({ - description: - "The unique identifier for this feature, used in /check and /track calls.", - }), - name: z.string().meta({ - description: - "Human-readable name displayed in the dashboard and billing UI.", - }), - eventNames: z.array(z.string()).optional().meta({ - description: - "Event names that trigger this feature's balance. Allows multiple features to respond to a single event.", - }), - creditSchema: z - .array( - z.object({ - metered_feature_id: z.string().meta({ - description: - "ID of the metered feature that draws from this credit system.", - }), - credit_cost: z.number().meta({ - description: "Credits consumed per unit of the metered feature.", - }), - }), - ) - .optional() - .meta({ - description: - "For credit_system features: maps metered features to their credit costs.", - }), - archived: z.boolean().meta({ - description: - "Whether the feature is archived and hidden from the dashboard.", - }) + id: z.string().meta({ + description: + "The unique identifier for this feature, used in /check and /track calls.", + }), + name: z.string().meta({ + description: + "Human-readable name displayed in the dashboard and billing UI.", + }), + eventNames: z.array(z.string()).optional().meta({ + description: + "Event names that trigger this feature's balance. Allows multiple features to respond to a single event.", + }), + creditSchema: z + .array( + z.object({ + metered_feature_id: z.string().meta({ + description: + "ID of the metered feature that draws from this credit system.", + }), + credit_cost: z.number().meta({ + description: "Credits consumed per unit of the metered feature.", + }), + }), + ) + .optional() + .meta({ + description: + "For credit_system features: maps metered features to their credit costs.", + }), + archived: z.boolean().meta({ + description: + "Whether the feature is archived and hidden from the dashboard.", + }), }); - - // Base fields shared by all feature types type FeatureBase = { - /** Unique identifier for the feature */ - id: string; - /** Display name for the feature */ - name: string; - /** Whether the feature is archived */ - archived?: boolean; - /** Event names that trigger this feature */ - eventNames?: string[]; - /** Credit schema for credit_system features */ - creditSchema?: Array<{ - meteredFeatureId: string; - creditCost: number; - }>; + /** Unique identifier for the feature */ + id: string; + /** Display name for the feature */ + name: string; + /** Whether the feature is archived */ + archived?: boolean; + /** Event names that trigger this feature */ + eventNames?: string[]; + /** Credit schema for credit_system features */ + creditSchema?: Array<{ + meteredFeatureId: string; + creditCost: number; + }>; }; /** Boolean feature - no consumable field allowed */ export type BooleanFeature = FeatureBase & { - type: "boolean"; - consumable?: never; + type: "boolean"; + consumable?: never; }; /** Metered feature - requires consumable field */ export type MeteredFeature = FeatureBase & { - type: "metered"; - /** Whether usage is consumed (true) or accumulated (false) */ - consumable: boolean; + type: "metered"; + /** Whether usage is consumed (true) or accumulated (false) */ + consumable: boolean; }; /** Credit system feature - always consumable */ export type CreditSystemFeature = FeatureBase & { - type: "credit_system"; - /** Credit systems are always consumable */ - consumable?: true; - /** Required: defines how credits map to metered features */ - creditSchema: Array<{ - meteredFeatureId: string; - creditCost: number; - }>; + type: "credit_system"; + /** Credit systems are always consumable */ + consumable?: true; + /** Required: defines how credits map to metered features */ + creditSchema: Array<{ + meteredFeatureId: string; + creditCost: number; + }>; }; -/** - * Feature definition with type-safe constraints: - * - Boolean features cannot have consumable - * - Metered features require consumable (true = single_use style, false = continuous_use style) - * - Credit system features are always consumable and require creditSchema - */ -export type Feature = BooleanFeature | MeteredFeature | CreditSystemFeature; +export type ModelMarkupEntry = { + /** Per-model markup override. Omit to inherit provider/global markup. */ + markup?: number; + inputCost?: number; + outputCost?: number; +}; +export type ProviderMarkupEntry = { + markup: number; +}; + +/** AI credit system feature - uses model-based pricing */ +export type AiCreditSystemFeature = FeatureBase & { + type: "ai_credit_system"; + /** Per-model markup overrides (highest priority). */ + modelMarkups?: Record; + /** Default markup applied when no model or provider markup matches. */ + defaultMarkup?: number; + /** Per-provider default markups, keyed by the first segment of the model id. */ + providerMarkups?: Record; +}; + +export type Feature = + | BooleanFeature + | MeteredFeature + | CreditSystemFeature + | AiCreditSystemFeature; diff --git a/packages/atmn/src/lib/transforms/apiToSdk/Transformer.test.ts b/packages/atmn/src/lib/transforms/apiToSdk/Transformer.test.ts index 0d77ee067..d8488addc 100644 --- a/packages/atmn/src/lib/transforms/apiToSdk/Transformer.test.ts +++ b/packages/atmn/src/lib/transforms/apiToSdk/Transformer.test.ts @@ -6,14 +6,14 @@ import { createTransformer } from "./Transformer.js"; describe("Transformer", () => { describe("Feature transforms", () => { test("boolean feature", () => { - const apiFeature = { + const result = transformApiFeature({ id: "enabled", name: "Feature Enabled", type: "boolean", + consumable: false, + archived: false, event_names: [], - }; - - const result = transformApiFeature(apiFeature); + }); expect(result.type).toBe("boolean"); expect(result.id).toBe("enabled"); @@ -21,47 +21,72 @@ describe("Transformer", () => { }); test("single_use → metered with consumable=true", () => { - const apiFeature = { + const result = transformApiFeature({ id: "api_calls", name: "API Calls", type: "single_use", + consumable: true, + archived: false, event_names: ["api.call"], - }; - - const result = transformApiFeature(apiFeature); + }); expect(result.type).toBe("metered"); - expect(result.consumable).toBe(true); + if (result.type === "metered") { + expect(result.consumable).toBe(true); + } expect(result.id).toBe("api_calls"); }); test("continuous_use → metered with consumable=false", () => { - const apiFeature = { + const result = transformApiFeature({ id: "seats", name: "Seats", type: "continuous_use", + consumable: false, + archived: false, event_names: [], - }; - - const result = transformApiFeature(apiFeature); + }); expect(result.type).toBe("metered"); - expect(result.consumable).toBe(false); + if (result.type === "metered") { + expect(result.consumable).toBe(false); + } }); test("credit_system", () => { - const apiFeature = { + const result = transformApiFeature({ id: "credits", name: "Credits", type: "credit_system", + consumable: true, + archived: false, credit_schema: [{ metered_feature_id: "api_calls", credit_cost: 10 }], - }; - - const result = transformApiFeature(apiFeature); + }); expect(result.type).toBe("credit_system"); - expect(result.consumable).toBe(true); - expect(result.creditSchema).toHaveLength(1); + if (result.type === "credit_system") { + expect(result.consumable).toBe(true); + expect(result.creditSchema).toHaveLength(1); + } + }); + + test("ai_credit_system", () => { + const result = transformApiFeature({ + id: "ai_credits", + name: "AI Credits", + type: "ai_credit_system", + consumable: true, + archived: false, + model_markups: { + "anthropic/claude-opus-4-5": { markup: 20 }, + }, + }); + + expect(result.type).toBe("ai_credit_system"); + if (result.type === "ai_credit_system") { + expect(result.modelMarkups).toBeDefined(); + expect(result.modelMarkups!["anthropic/claude-opus-4-5"].markup).toBe(20); + } }); }); diff --git a/packages/atmn/src/lib/transforms/apiToSdk/feature.ts b/packages/atmn/src/lib/transforms/apiToSdk/feature.ts index 9d5519e30..793025736 100644 --- a/packages/atmn/src/lib/transforms/apiToSdk/feature.ts +++ b/packages/atmn/src/lib/transforms/apiToSdk/feature.ts @@ -1,26 +1,38 @@ -import type { Feature } from "../../../compose/models/featureModels.js"; +import type { Feature, ModelMarkupEntry } from "../../../compose/models/featureModels.js"; +import type { ApiFeature } from "../../api/types/feature.js"; import { createTransformer } from "./Transformer.js"; +type RawApiFeature = Omit & { type: string }; + function mapCreditSchema( - api: any, + api: RawApiFeature, ): Array<{ meteredFeatureId: string; creditCost: number }> { - return (api.credit_schema ?? []).map( - (cs: { metered_feature_id: string; credit_cost: number }) => ({ - meteredFeatureId: cs.metered_feature_id, - creditCost: cs.credit_cost, - }), + return (api.credit_schema ?? []).map((cs) => ({ + meteredFeatureId: cs.metered_feature_id, + creditCost: cs.credit_cost, + })); +} + +function mapModelMarkups(api: RawApiFeature): Record | undefined { + if (!api.model_markups) return undefined; + return Object.fromEntries( + Object.entries(api.model_markups).map(([modelId, entry]) => [ + modelId, + { + markup: entry.markup, + inputCost: entry.input_cost, + outputCost: entry.output_cost, + }, + ]) ); } const BASE_COMPUTE = { - eventNames: (api: any) => + eventNames: (api: RawApiFeature) => api.event_names && api.event_names.length > 0 ? api.event_names : undefined, }; -/** - * Declarative feature transformer - replaces 79 lines with 40 lines of config - */ -export const featureTransformer = createTransformer({ +export const featureTransformer = createTransformer({ discriminator: "type", cases: { // Boolean features: just copy base fields, no consumable @@ -32,14 +44,24 @@ export const featureTransformer = createTransformer({ }, }, - // Credit system features: always consumable credit_system: { copy: ["id", "name", "archived"], compute: { ...BASE_COMPUTE, type: () => "credit_system" as const, consumable: () => true, - creditSchema: mapCreditSchema, + creditSchema: (api) => mapCreditSchema(api), + }, + }, + + ai_credit_system: { + copy: ["id", "name", "archived"], + compute: { + ...BASE_COMPUTE, + type: () => "ai_credit_system" as const, + modelMarkups: (api) => mapModelMarkups(api), + defaultMarkup: (api) => api.default_markup ?? undefined, + providerMarkups: (api) => api.provider_markups ?? undefined, }, }, @@ -85,6 +107,6 @@ export const featureTransformer = createTransformer({ }, }); -export function transformApiFeature(apiFeature: any): Feature { +export function transformApiFeature(apiFeature: RawApiFeature): Feature { return featureTransformer.transform(apiFeature); } diff --git a/packages/atmn/src/lib/transforms/sdkToApi/feature.ts b/packages/atmn/src/lib/transforms/sdkToApi/feature.ts index aa3b95f86..d963639e3 100644 --- a/packages/atmn/src/lib/transforms/sdkToApi/feature.ts +++ b/packages/atmn/src/lib/transforms/sdkToApi/feature.ts @@ -11,6 +11,13 @@ export interface ApiFeatureParams { metered_feature_id: string; credit_cost: number; }>; + model_markups?: Record; + default_markup?: number; + provider_markups?: Record; } export function transformFeatureToApi(feature: Feature): ApiFeatureParams { @@ -39,5 +46,26 @@ export function transformFeatureToApi(feature: Feature): ApiFeatureParams { })); } + 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 3e61d4e1a..eddbd30b1 100644 --- a/packages/atmn/src/lib/transforms/sdkToCode/feature.ts +++ b/packages/atmn/src/lib/transforms/sdkToCode/feature.ts @@ -42,6 +42,19 @@ export function buildFeatureCode(feature: Feature, varNameOverride?: string): st lines.push(`\tcreditSchema: ${formatValue(feature.creditSchema)},`); } + // 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(`});`); return lines.join("\n"); 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 92466a639..c0d21e93a 100644 --- a/packages/autumn-js/src/generated/attachSchemas.ts +++ b/packages/autumn-js/src/generated/attachSchemas.ts @@ -98,7 +98,7 @@ export const attachItemPriceOutboundSchema = z.object({ interval_count: z.number(), billing_units: z.number(), billing_method: z.string(), - max_purchase: z.union([z.number(), z.undefined()]).optional(), + max_purchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const attachItemProrationOutboundSchema = z.object({ @@ -150,7 +150,7 @@ export const attachAddItemPriceOutboundSchema = z.object({ interval_count: z.number(), billing_units: z.number(), billing_method: z.string(), - max_purchase: z.union([z.number(), z.undefined()]).optional(), + max_purchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const attachAddItemProrationOutboundSchema = z.object({ @@ -179,10 +179,16 @@ export const attachAddItemPlanItemOutboundSchema = z.object({ .optional(), }); +export const attachIntervalUnionOutboundSchema = z.union([ + z.string(), + z.string(), +]); + export const attachPlanItemFilterOutboundSchema = z.object({ feature_id: z.union([z.string(), z.undefined()]).optional(), billing_method: z.union([z.string(), z.undefined()]).optional(), - interval: z.union([z.string(), z.undefined()]).optional(), + interval: z.union([z.string(), z.string(), z.undefined()]).optional(), + interval_count: z.union([z.number(), z.undefined()]).optional(), }); export const attachFreeTrialParamsOutboundSchema = z.object({ @@ -320,7 +326,7 @@ export const attachItemPriceSchema = z.object({ intervalCount: z.union([z.number(), z.undefined()]).optional(), billingUnits: z.union([z.number(), z.undefined()]).optional(), billingMethod: attachItemBillingMethodSchema, - maxPurchase: z.union([z.number(), z.undefined()]).optional(), + maxPurchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const attachItemOnIncreaseSchema = closedEnumSchema; @@ -374,7 +380,7 @@ export const attachAddItemPriceSchema = z.object({ intervalCount: z.union([z.number(), z.undefined()]).optional(), billingUnits: z.union([z.number(), z.undefined()]).optional(), billingMethod: attachAddItemBillingMethodSchema, - maxPurchase: z.union([z.number(), z.undefined()]).optional(), + maxPurchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const attachAddItemOnIncreaseSchema = closedEnumSchema; @@ -407,14 +413,28 @@ export const attachAddItemPlanItemSchema = z.object({ export const attachRemoveItemBillingMethodSchema = closedEnumSchema; -export const attachRemoveItemIntervalSchema = closedEnumSchema; +export const attachIntervalRemoveItemEnum2Schema = closedEnumSchema; + +export const attachIntervalRemoveItemEnum1Schema = closedEnumSchema; + +export const attachIntervalUnionSchema = z.union([ + attachIntervalRemoveItemEnum1Schema, + attachIntervalRemoveItemEnum2Schema, +]); export const attachPlanItemFilterSchema = z.object({ featureId: z.union([z.string(), z.undefined()]).optional(), billingMethod: z .union([attachRemoveItemBillingMethodSchema, z.undefined()]) .optional(), - interval: z.union([attachRemoveItemIntervalSchema, z.undefined()]).optional(), + interval: z + .union([ + attachIntervalRemoveItemEnum1Schema, + attachIntervalRemoveItemEnum2Schema, + z.undefined(), + ]) + .optional(), + intervalCount: z.union([z.number(), z.undefined()]).optional(), }); export const attachDurationTypeSchema = closedEnumSchema; diff --git a/packages/autumn-js/src/generated/listPlansSchemas.ts b/packages/autumn-js/src/generated/listPlansSchemas.ts index ae2564aeb..4346acff0 100644 --- a/packages/autumn-js/src/generated/listPlansSchemas.ts +++ b/packages/autumn-js/src/generated/listPlansSchemas.ts @@ -26,6 +26,14 @@ export const listPlansCreditSchemaSchema = z.object({ creditCost: z.number(), }); +export const listPlansToSchema = z.union([z.number(), z.string()]); + +export const listPlansTierSchema = z.object({ + to: z.union([z.number(), z.string()]), + amount: z.number(), + flatAmount: z.union([z.number(), z.undefined()]).optional(), +}); + export const listPlansItemDisplaySchema = z.object({ primaryText: z.string(), secondaryText: z.union([z.string(), z.undefined()]).optional(), @@ -84,7 +92,7 @@ export const listPlansBillingMethodSchema = openEnumSchema; export const listPlansItemPriceSchema = z.object({ amount: z.union([z.number(), z.undefined()]).optional(), - tiers: z.union([z.array(z.any().nullable()), z.undefined()]).optional(), + tiers: z.union([z.array(listPlansTierSchema), z.undefined()]).optional(), tierBehavior: z .union([listPlansTierBehaviorSchema, z.undefined()]) .optional(), diff --git a/packages/autumn-js/src/generated/multiAttachSchemas.ts b/packages/autumn-js/src/generated/multiAttachSchemas.ts index 2502c8a8f..654290de9 100644 --- a/packages/autumn-js/src/generated/multiAttachSchemas.ts +++ b/packages/autumn-js/src/generated/multiAttachSchemas.ts @@ -80,7 +80,7 @@ export const multiAttachPriceOutboundSchema = z.object({ interval_count: z.number(), billing_units: z.number(), billing_method: z.string(), - max_purchase: z.union([z.number(), z.undefined()]).optional(), + max_purchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const multiAttachProrationOutboundSchema = z.object({ @@ -235,7 +235,7 @@ export const multiAttachPriceSchema = z.object({ intervalCount: z.union([z.number(), z.undefined()]).optional(), billingUnits: z.union([z.number(), z.undefined()]).optional(), billingMethod: multiAttachBillingMethodSchema, - maxPurchase: z.union([z.number(), z.undefined()]).optional(), + maxPurchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const multiAttachOnIncreaseSchema = closedEnumSchema; diff --git a/packages/autumn-js/src/generated/previewAttachSchemas.ts b/packages/autumn-js/src/generated/previewAttachSchemas.ts index 1bf684170..f76e43a85 100644 --- a/packages/autumn-js/src/generated/previewAttachSchemas.ts +++ b/packages/autumn-js/src/generated/previewAttachSchemas.ts @@ -186,7 +186,7 @@ export const previewAttachItemPriceOutboundSchema = z.object({ interval_count: z.number(), billing_units: z.number(), billing_method: z.string(), - max_purchase: z.union([z.number(), z.undefined()]).optional(), + max_purchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const previewAttachItemProrationOutboundSchema = z.object({ @@ -245,7 +245,7 @@ export const previewAttachAddItemPriceOutboundSchema = z.object({ interval_count: z.number(), billing_units: z.number(), billing_method: z.string(), - max_purchase: z.union([z.number(), z.undefined()]).optional(), + max_purchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const previewAttachAddItemProrationOutboundSchema = z.object({ @@ -278,10 +278,16 @@ export const previewAttachAddItemPlanItemOutboundSchema = z.object({ .optional(), }); +export const previewAttachIntervalUnionOutboundSchema = z.union([ + z.string(), + z.string(), +]); + export const previewAttachPlanItemFilterOutboundSchema = z.object({ feature_id: z.union([z.string(), z.undefined()]).optional(), billing_method: z.union([z.string(), z.undefined()]).optional(), - interval: z.union([z.string(), z.undefined()]).optional(), + interval: z.union([z.string(), z.string(), z.undefined()]).optional(), + interval_count: z.union([z.number(), z.undefined()]).optional(), }); export const previewAttachFreeTrialParamsOutboundSchema = z.object({ @@ -428,7 +434,7 @@ export const previewAttachItemPriceSchema = z.object({ intervalCount: z.union([z.number(), z.undefined()]).optional(), billingUnits: z.union([z.number(), z.undefined()]).optional(), billingMethod: previewAttachItemBillingMethodSchema, - maxPurchase: z.union([z.number(), z.undefined()]).optional(), + maxPurchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const previewAttachItemOnIncreaseSchema = closedEnumSchema; @@ -488,7 +494,7 @@ export const previewAttachAddItemPriceSchema = z.object({ intervalCount: z.union([z.number(), z.undefined()]).optional(), billingUnits: z.union([z.number(), z.undefined()]).optional(), billingMethod: previewAttachAddItemBillingMethodSchema, - maxPurchase: z.union([z.number(), z.undefined()]).optional(), + maxPurchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const previewAttachAddItemOnIncreaseSchema = closedEnumSchema; @@ -525,7 +531,14 @@ export const previewAttachAddItemPlanItemSchema = z.object({ export const previewAttachRemoveItemBillingMethodSchema = closedEnumSchema; -export const previewAttachRemoveItemIntervalSchema = closedEnumSchema; +export const previewAttachIntervalRemoveItemEnum2Schema = closedEnumSchema; + +export const previewAttachIntervalRemoveItemEnum1Schema = closedEnumSchema; + +export const previewAttachIntervalUnionSchema = z.union([ + previewAttachIntervalRemoveItemEnum1Schema, + previewAttachIntervalRemoveItemEnum2Schema, +]); export const previewAttachPlanItemFilterSchema = z.object({ featureId: z.union([z.string(), z.undefined()]).optional(), @@ -533,8 +546,13 @@ export const previewAttachPlanItemFilterSchema = z.object({ .union([previewAttachRemoveItemBillingMethodSchema, z.undefined()]) .optional(), interval: z - .union([previewAttachRemoveItemIntervalSchema, z.undefined()]) + .union([ + previewAttachIntervalRemoveItemEnum1Schema, + previewAttachIntervalRemoveItemEnum2Schema, + z.undefined(), + ]) .optional(), + intervalCount: z.union([z.number(), z.undefined()]).optional(), }); export const previewAttachDurationTypeSchema = closedEnumSchema; diff --git a/packages/autumn-js/src/generated/previewMultiAttachSchemas.ts b/packages/autumn-js/src/generated/previewMultiAttachSchemas.ts index f86f80df9..bbe5995e7 100644 --- a/packages/autumn-js/src/generated/previewMultiAttachSchemas.ts +++ b/packages/autumn-js/src/generated/previewMultiAttachSchemas.ts @@ -168,7 +168,7 @@ export const previewMultiAttachPriceOutboundSchema = z.object({ interval_count: z.number(), billing_units: z.number(), billing_method: z.string(), - max_purchase: z.union([z.number(), z.undefined()]).optional(), + max_purchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const previewMultiAttachProrationOutboundSchema = z.object({ @@ -337,7 +337,7 @@ export const previewMultiAttachPriceSchema = z.object({ intervalCount: z.union([z.number(), z.undefined()]).optional(), billingUnits: z.union([z.number(), z.undefined()]).optional(), billingMethod: previewMultiAttachBillingMethodSchema, - maxPurchase: z.union([z.number(), z.undefined()]).optional(), + maxPurchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const previewMultiAttachOnIncreaseSchema = closedEnumSchema; diff --git a/packages/autumn-js/src/generated/previewUpdateSubscriptionSchemas.ts b/packages/autumn-js/src/generated/previewUpdateSubscriptionSchemas.ts index 732a6c7af..6120bee27 100644 --- a/packages/autumn-js/src/generated/previewUpdateSubscriptionSchemas.ts +++ b/packages/autumn-js/src/generated/previewUpdateSubscriptionSchemas.ts @@ -175,7 +175,7 @@ export const previewUpdateItemPriceOutboundSchema = z.object({ interval_count: z.number(), billing_units: z.number(), billing_method: z.string(), - max_purchase: z.union([z.number(), z.undefined()]).optional(), + max_purchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const previewUpdateItemProrationOutboundSchema = z.object({ @@ -234,7 +234,7 @@ export const previewUpdateAddItemPriceOutboundSchema = z.object({ interval_count: z.number(), billing_units: z.number(), billing_method: z.string(), - max_purchase: z.union([z.number(), z.undefined()]).optional(), + max_purchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const previewUpdateAddItemProrationOutboundSchema = z.object({ @@ -267,10 +267,16 @@ export const previewUpdateAddItemPlanItemOutboundSchema = z.object({ .optional(), }); +export const previewUpdateIntervalUnionOutboundSchema = z.union([ + z.string(), + z.string(), +]); + export const previewUpdatePlanItemFilterOutboundSchema = z.object({ feature_id: z.union([z.string(), z.undefined()]).optional(), billing_method: z.union([z.string(), z.undefined()]).optional(), - interval: z.union([z.string(), z.undefined()]).optional(), + interval: z.union([z.string(), z.string(), z.undefined()]).optional(), + interval_count: z.union([z.number(), z.undefined()]).optional(), }); export const previewUpdateFreeTrialParamsOutboundSchema = z.object({ @@ -387,7 +393,7 @@ export const previewUpdateItemPriceSchema = z.object({ intervalCount: z.union([z.number(), z.undefined()]).optional(), billingUnits: z.union([z.number(), z.undefined()]).optional(), billingMethod: previewUpdateItemBillingMethodSchema, - maxPurchase: z.union([z.number(), z.undefined()]).optional(), + maxPurchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const previewUpdateItemOnIncreaseSchema = closedEnumSchema; @@ -447,7 +453,7 @@ export const previewUpdateAddItemPriceSchema = z.object({ intervalCount: z.union([z.number(), z.undefined()]).optional(), billingUnits: z.union([z.number(), z.undefined()]).optional(), billingMethod: previewUpdateAddItemBillingMethodSchema, - maxPurchase: z.union([z.number(), z.undefined()]).optional(), + maxPurchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const previewUpdateAddItemOnIncreaseSchema = closedEnumSchema; @@ -484,7 +490,14 @@ export const previewUpdateAddItemPlanItemSchema = z.object({ export const previewUpdateRemoveItemBillingMethodSchema = closedEnumSchema; -export const previewUpdateRemoveItemIntervalSchema = closedEnumSchema; +export const previewUpdateIntervalRemoveItemEnum2Schema = closedEnumSchema; + +export const previewUpdateIntervalRemoveItemEnum1Schema = closedEnumSchema; + +export const previewUpdateIntervalUnionSchema = z.union([ + previewUpdateIntervalRemoveItemEnum1Schema, + previewUpdateIntervalRemoveItemEnum2Schema, +]); export const previewUpdatePlanItemFilterSchema = z.object({ featureId: z.union([z.string(), z.undefined()]).optional(), @@ -492,8 +505,13 @@ export const previewUpdatePlanItemFilterSchema = z.object({ .union([previewUpdateRemoveItemBillingMethodSchema, z.undefined()]) .optional(), interval: z - .union([previewUpdateRemoveItemIntervalSchema, z.undefined()]) + .union([ + previewUpdateIntervalRemoveItemEnum1Schema, + previewUpdateIntervalRemoveItemEnum2Schema, + z.undefined(), + ]) .optional(), + intervalCount: z.union([z.number(), z.undefined()]).optional(), }); export const previewUpdateDurationTypeSchema = closedEnumSchema; diff --git a/packages/autumn-js/src/generated/setupPaymentSchemas.ts b/packages/autumn-js/src/generated/setupPaymentSchemas.ts index 26fcdb50d..2049ed50b 100644 --- a/packages/autumn-js/src/generated/setupPaymentSchemas.ts +++ b/packages/autumn-js/src/generated/setupPaymentSchemas.ts @@ -91,7 +91,7 @@ export const setupPaymentItemPriceOutboundSchema = z.object({ interval_count: z.number(), billing_units: z.number(), billing_method: z.string(), - max_purchase: z.union([z.number(), z.undefined()]).optional(), + max_purchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const setupPaymentItemProrationOutboundSchema = z.object({ @@ -150,7 +150,7 @@ export const setupPaymentAddItemPriceOutboundSchema = z.object({ interval_count: z.number(), billing_units: z.number(), billing_method: z.string(), - max_purchase: z.union([z.number(), z.undefined()]).optional(), + max_purchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const setupPaymentAddItemProrationOutboundSchema = z.object({ @@ -183,10 +183,16 @@ export const setupPaymentAddItemPlanItemOutboundSchema = z.object({ .optional(), }); +export const setupPaymentIntervalUnionOutboundSchema = z.union([ + z.string(), + z.string(), +]); + export const setupPaymentPlanItemFilterOutboundSchema = z.object({ feature_id: z.union([z.string(), z.undefined()]).optional(), billing_method: z.union([z.string(), z.undefined()]).optional(), - interval: z.union([z.string(), z.undefined()]).optional(), + interval: z.union([z.string(), z.string(), z.undefined()]).optional(), + interval_count: z.union([z.number(), z.undefined()]).optional(), }); export const setupPaymentFreeTrialParamsOutboundSchema = z.object({ @@ -312,7 +318,7 @@ export const setupPaymentItemPriceSchema = z.object({ intervalCount: z.union([z.number(), z.undefined()]).optional(), billingUnits: z.union([z.number(), z.undefined()]).optional(), billingMethod: setupPaymentItemBillingMethodSchema, - maxPurchase: z.union([z.number(), z.undefined()]).optional(), + maxPurchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const setupPaymentItemOnIncreaseSchema = closedEnumSchema; @@ -370,7 +376,7 @@ export const setupPaymentAddItemPriceSchema = z.object({ intervalCount: z.union([z.number(), z.undefined()]).optional(), billingUnits: z.union([z.number(), z.undefined()]).optional(), billingMethod: setupPaymentAddItemBillingMethodSchema, - maxPurchase: z.union([z.number(), z.undefined()]).optional(), + maxPurchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const setupPaymentAddItemOnIncreaseSchema = closedEnumSchema; @@ -407,7 +413,14 @@ export const setupPaymentAddItemPlanItemSchema = z.object({ export const setupPaymentRemoveItemBillingMethodSchema = closedEnumSchema; -export const setupPaymentRemoveItemIntervalSchema = closedEnumSchema; +export const setupPaymentIntervalRemoveItemEnum2Schema = closedEnumSchema; + +export const setupPaymentIntervalRemoveItemEnum1Schema = closedEnumSchema; + +export const setupPaymentIntervalUnionSchema = z.union([ + setupPaymentIntervalRemoveItemEnum1Schema, + setupPaymentIntervalRemoveItemEnum2Schema, +]); export const setupPaymentPlanItemFilterSchema = z.object({ featureId: z.union([z.string(), z.undefined()]).optional(), @@ -415,8 +428,13 @@ export const setupPaymentPlanItemFilterSchema = z.object({ .union([setupPaymentRemoveItemBillingMethodSchema, z.undefined()]) .optional(), interval: z - .union([setupPaymentRemoveItemIntervalSchema, z.undefined()]) + .union([ + setupPaymentIntervalRemoveItemEnum1Schema, + setupPaymentIntervalRemoveItemEnum2Schema, + z.undefined(), + ]) .optional(), + intervalCount: z.union([z.number(), z.undefined()]).optional(), }); export const setupPaymentDurationTypeSchema = closedEnumSchema; diff --git a/packages/autumn-js/src/generated/updateSubscriptionSchemas.ts b/packages/autumn-js/src/generated/updateSubscriptionSchemas.ts index 1099bee89..2657928bb 100644 --- a/packages/autumn-js/src/generated/updateSubscriptionSchemas.ts +++ b/packages/autumn-js/src/generated/updateSubscriptionSchemas.ts @@ -90,7 +90,7 @@ export const billingUpdateItemPriceOutboundSchema = z.object({ interval_count: z.number(), billing_units: z.number(), billing_method: z.string(), - max_purchase: z.union([z.number(), z.undefined()]).optional(), + max_purchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const billingUpdateItemProrationOutboundSchema = z.object({ @@ -149,7 +149,7 @@ export const billingUpdateAddItemPriceOutboundSchema = z.object({ interval_count: z.number(), billing_units: z.number(), billing_method: z.string(), - max_purchase: z.union([z.number(), z.undefined()]).optional(), + max_purchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const billingUpdateAddItemProrationOutboundSchema = z.object({ @@ -182,10 +182,16 @@ export const billingUpdateAddItemPlanItemOutboundSchema = z.object({ .optional(), }); +export const billingUpdateIntervalUnionOutboundSchema = z.union([ + z.string(), + z.string(), +]); + export const billingUpdatePlanItemFilterOutboundSchema = z.object({ feature_id: z.union([z.string(), z.undefined()]).optional(), billing_method: z.union([z.string(), z.undefined()]).optional(), - interval: z.union([z.string(), z.undefined()]).optional(), + interval: z.union([z.string(), z.string(), z.undefined()]).optional(), + interval_count: z.union([z.number(), z.undefined()]).optional(), }); export const billingUpdateFreeTrialParamsOutboundSchema = z.object({ @@ -297,7 +303,7 @@ export const billingUpdateItemPriceSchema = z.object({ intervalCount: z.union([z.number(), z.undefined()]).optional(), billingUnits: z.union([z.number(), z.undefined()]).optional(), billingMethod: billingUpdateItemBillingMethodSchema, - maxPurchase: z.union([z.number(), z.undefined()]).optional(), + maxPurchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const billingUpdateItemOnIncreaseSchema = closedEnumSchema; @@ -357,7 +363,7 @@ export const billingUpdateAddItemPriceSchema = z.object({ intervalCount: z.union([z.number(), z.undefined()]).optional(), billingUnits: z.union([z.number(), z.undefined()]).optional(), billingMethod: billingUpdateAddItemBillingMethodSchema, - maxPurchase: z.union([z.number(), z.undefined()]).optional(), + maxPurchase: z.union([z.number(), z.undefined()]).optional().nullable(), }); export const billingUpdateAddItemOnIncreaseSchema = closedEnumSchema; @@ -394,7 +400,14 @@ export const billingUpdateAddItemPlanItemSchema = z.object({ export const billingUpdateRemoveItemBillingMethodSchema = closedEnumSchema; -export const billingUpdateRemoveItemIntervalSchema = closedEnumSchema; +export const billingUpdateIntervalRemoveItemEnum2Schema = closedEnumSchema; + +export const billingUpdateIntervalRemoveItemEnum1Schema = closedEnumSchema; + +export const billingUpdateIntervalUnionSchema = z.union([ + billingUpdateIntervalRemoveItemEnum1Schema, + billingUpdateIntervalRemoveItemEnum2Schema, +]); export const billingUpdatePlanItemFilterSchema = z.object({ featureId: z.union([z.string(), z.undefined()]).optional(), @@ -402,8 +415,13 @@ export const billingUpdatePlanItemFilterSchema = z.object({ .union([billingUpdateRemoveItemBillingMethodSchema, z.undefined()]) .optional(), interval: z - .union([billingUpdateRemoveItemIntervalSchema, z.undefined()]) + .union([ + billingUpdateIntervalRemoveItemEnum1Schema, + billingUpdateIntervalRemoveItemEnum2Schema, + z.undefined(), + ]) .optional(), + intervalCount: z.union([z.number(), z.undefined()]).optional(), }); export const billingUpdateDurationTypeSchema = closedEnumSchema; diff --git a/packages/openapi/openapi-stripped.yml b/packages/openapi/openapi-stripped.yml index 02c3117f8..97407beb2 100644 --- a/packages/openapi/openapi-stripped.yml +++ b/packages/openapi/openapi-stripped.yml @@ -586,10 +586,11 @@ components: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit - pools." + pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -617,6 +618,45 @@ components: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -1089,6 +1129,7 @@ components: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -1178,9 +1219,19 @@ components: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. @@ -1410,10 +1461,11 @@ components: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit - pools." + pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -1441,6 +1493,45 @@ components: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -1567,9 +1658,19 @@ components: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration if applicable. tier_behavior: enum: @@ -2328,10 +2429,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -2359,6 +2462,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -3203,10 +3345,13 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' - for unified credit pools." + for unified credit pools, + 'ai_credit_system' for model-based token + pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -3234,6 +3379,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -4056,10 +4240,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -4087,6 +4273,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -4464,9 +4689,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -4715,6 +4943,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -4804,9 +5033,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. @@ -5208,6 +5447,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -5297,9 +5537,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. @@ -5706,6 +5956,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -5796,9 +6047,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. @@ -6233,9 +6494,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -6353,6 +6617,8 @@ paths: pattern: ^[a-zA-Z0-9_-]+$ description: The new ID to use for the plan. Can only be updated if the plan has not been used by any customers. + disable_version: + type: boolean required: - plan_id title: UpdatePlanParams @@ -6470,6 +6736,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -6559,9 +6826,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. @@ -6904,6 +7181,7 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features @@ -6940,8 +7218,51 @@ paths: required: - metered_feature_id - credit_cost - description: A schema that maps 'single_use' feature IDs to credit costs. - Applicable only for 'credit_system' features. + description: A schema that maps 'single_use' feature IDs to credit costs. For + classic credit systems only — AI credit systems use + model_markups instead. + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. Maps model IDs to + their markup configuration. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for this AI credit system. Used when no + model or provider markup applies. Use -100 to make usage + free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. + Provider keys match the first segment of model_id. event_names: type: array items: @@ -6990,10 +7311,11 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit - pools." + pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -7021,6 +7343,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -7105,10 +7466,11 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit - pools." + pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -7136,6 +7498,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -7210,10 +7611,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified - credit pools." + credit pools, 'ai_credit_system' for model-based + token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -7241,6 +7644,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -7322,6 +7764,7 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features @@ -7358,8 +7801,51 @@ paths: required: - metered_feature_id - credit_cost - description: A schema that maps 'single_use' feature IDs to credit costs. - Applicable only for 'credit_system' features. + description: A schema that maps 'single_use' feature IDs to credit costs. For + classic credit systems only — AI credit systems use + model_markups instead. + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. Maps model IDs to + their markup configuration. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for this AI credit system. Used when no + model or provider markup applies. Use -100 to make usage + free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. + Provider keys match the first segment of model_id. event_names: type: array items: @@ -7408,10 +7894,11 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit - pools." + pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -7439,6 +7926,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -7704,9 +8230,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -7766,7 +8295,7 @@ paths: pricing, and rollover settings. description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items - / update_items. + / deprecated update_items. add_items: type: array items: @@ -7862,9 +8391,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -7938,15 +8470,37 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). @@ -8537,10 +9091,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 - total. + total. Null for no limit. required: - interval - billing_method @@ -8599,10 +9155,219 @@ paths: title: PlanItem description: Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. - description: Override the items in the plan. - additionalProperties: false - description: Customize the plan to schedule. Can override the price, items, or - both. + 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: + type: array + items: + type: object + properties: + feature_id: + type: string + description: The ID of the feature to configure. + included: + type: number + description: Number of free units included. Balance resets to this each interval + for consumable features. + unlimited: + type: boolean + description: If true, customer has unlimited access to this feature. + reset: + type: object + properties: + interval: + enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + description: Interval at which balance resets (e.g. 'month', 'year'). For + consumable features only. + interval_count: + type: number + description: Number of intervals between resets. Defaults to 1. + required: + - interval + description: Reset configuration for consumable features. Omit for + non-consumable features like seats. + price: + type: object + properties: + amount: + type: number + description: Price per billing_units after included usage. Either 'amount' or + 'tiers' is required. + tiers: + type: array + items: + type: object + properties: + to: {} + amount: {} + flat_amount: {} + description: Tiered pricing. Either 'amount' or 'tiers' is required. + tier_behavior: + enum: + - graduated + - volume + type: string + interval: + enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + description: Billing interval. For consumable features, should match + reset.interval. + interval_count: + type: number + default: 1 + description: Number of intervals per billing cycle. Defaults to 1. + billing_units: + type: number + default: 1 + description: Units per price increment. Usage is rounded UP when billed (e.g. + billing_units=100 means 101 + rounds to 200). + billing_method: + enum: + - prepaid + - usage_based + type: string + description: "'prepaid' for upfront payment (seats), 'usage_based' for + pay-as-you-go." + max_purchase: + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, + max_purchase=300 allows 400 + total. Null for no limit. + required: + - interval + - billing_method + description: Pricing for usage beyond included units. Omit for free features. + proration: + type: object + properties: + on_increase: + enum: + - bill_immediately + - prorate_immediately + - prorate_next_cycle + - bill_next_cycle + type: string + description: Billing behavior when quantity increases mid-cycle. + on_decrease: + enum: + - prorate + - prorate_immediately + - prorate_next_cycle + - none + - no_prorations + type: string + description: Credit behavior when quantity decreases mid-cycle. + required: + - on_increase + - on_decrease + description: Proration settings for prepaid features. Controls mid-cycle + quantity change billing. + rollover: + type: object + properties: + max: + type: number + description: Max rollover units. Omit for unlimited rollover. + max_percentage: + type: number + description: Maximum rollover as a percentage (0-100) of included + prepaid + grant. Mutually exclusive with + max. + expiry_duration_type: + enum: + - month + - forever + type: string + description: When rolled over units expire. + expiry_duration_length: + type: number + description: Number of periods before expiry. + required: + - expiry_duration_type + description: Rollover config for unused units. If set, unused included units + carry over. + required: + - feature_id + title: PlanItem + description: Configuration for a feature item in a plan, including usage limits, + pricing, and rollover settings. + description: Items to add to the plan. + remove_items: + type: array + items: + type: object + properties: + feature_id: + type: string + description: Match items linked to this feature. + billing_method: + enum: + - prepaid + - usage_based + type: string + description: Match items with this billing method (prepaid or usage_based). + interval: + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in + count. + title: PlanItemFilter + description: Filter for matching plan items. All provided fields must match + (AND). + description: Filters selecting items to remove from the plan. + description: Customize the plan to schedule. Can override price, replace items, + or patch items with add_items, remove_items, + and update_items. subscription_id: type: string description: A unique ID to identify this subscription. Useful when scheduling @@ -8771,9 +9536,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + Null for no limit. required: - interval - billing_method @@ -8832,10 +9600,218 @@ paths: title: PlanItem description: Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. - description: Override the items in the plan. - additionalProperties: false - description: Customize the plan to schedule. Can override the price, items, or - both. + 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: + type: array + items: + type: object + properties: + feature_id: + type: string + description: The ID of the feature to configure. + included: + type: number + description: Number of free units included. Balance resets to this each interval + for consumable features. + unlimited: + type: boolean + description: If true, customer has unlimited access to this feature. + reset: + type: object + properties: + interval: + enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + description: Interval at which balance resets (e.g. 'month', 'year'). For + consumable features only. + interval_count: + type: number + description: Number of intervals between resets. Defaults to 1. + required: + - interval + description: Reset configuration for consumable features. Omit for + non-consumable features like seats. + price: + type: object + properties: + amount: + type: number + description: Price per billing_units after included usage. Either 'amount' or + 'tiers' is required. + tiers: + type: array + items: + type: object + properties: + to: {} + amount: {} + flat_amount: {} + description: Tiered pricing. Either 'amount' or 'tiers' is required. + tier_behavior: + enum: + - graduated + - volume + type: string + interval: + enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + description: Billing interval. For consumable features, should match + reset.interval. + interval_count: + type: number + default: 1 + description: Number of intervals per billing cycle. Defaults to 1. + billing_units: + type: number + default: 1 + description: Units per price increment. Usage is rounded UP when billed (e.g. + billing_units=100 means 101 rounds + to 200). + billing_method: + enum: + - prepaid + - usage_based + type: string + description: "'prepaid' for upfront payment (seats), 'usage_based' for + pay-as-you-go." + max_purchase: + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, + max_purchase=300 allows 400 total. + Null for no limit. + required: + - interval + - billing_method + description: Pricing for usage beyond included units. Omit for free features. + proration: + type: object + properties: + on_increase: + enum: + - bill_immediately + - prorate_immediately + - prorate_next_cycle + - bill_next_cycle + type: string + description: Billing behavior when quantity increases mid-cycle. + on_decrease: + enum: + - prorate + - prorate_immediately + - prorate_next_cycle + - none + - no_prorations + type: string + description: Credit behavior when quantity decreases mid-cycle. + required: + - on_increase + - on_decrease + description: Proration settings for prepaid features. Controls mid-cycle + quantity change billing. + rollover: + type: object + properties: + max: + type: number + description: Max rollover units. Omit for unlimited rollover. + max_percentage: + type: number + description: Maximum rollover as a percentage (0-100) of included + prepaid + grant. Mutually exclusive with + max. + expiry_duration_type: + enum: + - month + - forever + type: string + description: When rolled over units expire. + expiry_duration_length: + type: number + description: Number of periods before expiry. + required: + - expiry_duration_type + description: Rollover config for unused units. If set, unused included units + carry over. + required: + - feature_id + title: PlanItem + description: Configuration for a feature item in a plan, including usage limits, + pricing, and rollover settings. + description: Items to add to the plan. + remove_items: + type: array + items: + type: object + properties: + feature_id: + type: string + description: Match items linked to this feature. + billing_method: + enum: + - prepaid + - usage_based + type: string + description: Match items with this billing method (prepaid or usage_based). + interval: + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in + count. + title: PlanItemFilter + description: Filter for matching plan items. All provided fields must match + (AND). + description: Filters selecting items to remove from the plan. + description: Customize the plan to schedule. Can override price, replace items, + or patch items with add_items, remove_items, and + update_items. subscription_id: type: string description: A unique ID to identify this subscription. Useful when scheduling @@ -9157,9 +10133,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null + for no limit. required: - interval - billing_method @@ -9728,9 +10707,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -9790,7 +10772,7 @@ paths: pricing, and rollover settings. description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items - / update_items. + / deprecated update_items. add_items: type: array items: @@ -9886,9 +10868,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -9962,15 +10947,37 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). @@ -10778,9 +11785,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null + for no limit. required: - interval - billing_method @@ -11674,9 +12684,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -11736,7 +12749,7 @@ paths: pricing, and rollover settings. description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items - / update_items. + / deprecated update_items. add_items: type: array items: @@ -11832,9 +12845,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -11908,15 +12924,37 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). @@ -12335,9 +13373,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -12397,7 +13438,7 @@ paths: pricing, and rollover settings. description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items - / update_items. + / deprecated update_items. add_items: type: array items: @@ -12493,9 +13534,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -12569,15 +13613,37 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). @@ -13369,9 +14435,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -13431,7 +14500,7 @@ paths: pricing, and rollover settings. description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items - / update_items. + / deprecated update_items. add_items: type: array items: @@ -13527,9 +14596,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -13603,15 +14675,37 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). @@ -14319,10 +15413,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -14350,6 +15446,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -14868,10 +16003,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -14899,6 +16036,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -15712,6 +16888,393 @@ paths: x-speakeasy-name-override: track parameters: - *a5 + /v1/balances.track_tokens: + post: + operationId: trackTokens + description: >- + Records AI token usage for a customer and returns the updated AI credit + balance. + + + Use this after an LLM request when you have input and output token + counts. Autumn converts token usage to a dollar amount using the + configured model pricing and markup, then tracks that value against the + customer's AI credit system. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + customer_id: + type: string + description: The ID of the customer. + entity_id: + type: string + description: The ID of the entity for entity-scoped balances. + feature_id: + type: string + description: The ID of the AI credit system feature. Auto-detected from the + customer's entitlements if omitted — only required when a + customer has multiple AI credit systems. + model_id: + type: string + description: The AI model as '/' (e.g. + 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). + The provider is the first path segment and must match a + provider + model key in models.dev. + input_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of non-cached text input tokens consumed. Exclusive of cache + and audio token pools. + output_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of text output tokens consumed. Exclusive of the reasoning + and audio output pools. + cache_read_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of cached input tokens read. + cache_write_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of input tokens written to the cache. + audio_input_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of audio input tokens consumed. + audio_output_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of audio output tokens generated. + reasoning_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of reasoning tokens generated. + properties: + type: object + propertyNames: + type: string + additionalProperties: {} + description: Additional properties to attach to this usage event. + required: + - customer_id + - model_id + - input_tokens + - output_tokens + title: TrackTokensParams + examples: + - &a57 + customer_id: cus_123 + feature_id: ai_credits + model_id: anthropic/claude-sonnet-4-20250514 + input_tokens: 1000 + output_tokens: 500 + example: *a57 + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + customer_id: + type: string + description: The ID of the customer whose usage was tracked. + entity_id: + type: string + description: The ID of the entity, if entity-scoped tracking was performed. + event_name: + type: string + description: The event name that was tracked, if event_name was used instead of + feature_id. + value: + type: number + description: The amount of usage that was recorded. + balance: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: The updated balance for the tracked feature. Null if tracking by + event_name that affects multiple features. + balances: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: Map of feature_id to updated balance for the tracked feature and + any related features (e.g. linked credit systems). Value + is null when the customer has no balance for that feature. + deductions: + type: array + items: + type: object + properties: + balance_id: + type: string + description: ID of the underlying balance row that was deducted from + (customer_entitlement or rollover). + feature_id: + type: string + description: The feature this balance belongs to. + plan_id: + anyOf: + - type: string + - type: "null" + description: ID of the plan/product this balance belongs to. Null when the + balance can't be attributed to a single plan (e.g. + it spans multiple). + reset: + anyOf: + - type: object + properties: + interval: + anyOf: + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + - const: multiple + description: The reset interval (hour, day, week, month, etc.) or 'multiple' if + combined from different intervals. + interval_count: + type: number + description: Number of intervals between resets (eg. 2 for bi-monthly). + resets_at: + anyOf: + - type: number + - type: "null" + description: Timestamp when the balance will next reset. + required: + - interval + - resets_at + - type: "null" + description: Reset configuration for the balance this deduction came from, or + null if the balance doesn't reset. + value: + type: number + description: Amount deducted from this balance. Positive when usage was + consumed, negative when credit was restored (e.g. a + refund via negative track value). + required: + - balance_id + - feature_id + - plan_id + - reset + - value + description: Per-balance breakdown of what this event deducted. A single event + can consume from multiple balance rows when credit systems + or rollovers are involved; this surfaces each one so + callers can build per-feature usage views without polling. + required: + - customer_id + - value + - balance + examples: + - &a58 + customer_id: cus_123 + value: 0.006 + balance: + feature_id: ai_credits + granted: 10 + remaining: 9.994 + usage: 0.006 + unlimited: false + overage_allowed: false + max_purchase: null + next_reset_at: 1773851121437 + breakdown: + - id: cus_ent_39qmLooixXLAqMywgXywjAz96rV + plan_id: pro_plan + included_grant: 100 + prepaid_grant: 0 + remaining: 72 + usage: 28 + unlimited: false + reset: + interval: month + resets_at: 1773851121437 + price: null + expires_at: null + deductions: + - balance_id: cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2 + feature_id: ai_credits + plan_id: pro + reset: + interval: month + resets_at: 1781288736881 + value: 0.006 + example: *a58 + "202": + description: Accepted. Autumn is experiencing degraded service from a downstream + provider, so the token usage event was accepted for replay and will + be tracked as soon as the service is restored. + content: + application/json: + schema: + type: object + properties: + customer_id: + type: string + description: The ID of the customer whose usage was tracked. + entity_id: + type: string + description: The ID of the entity, if entity-scoped tracking was performed. + event_name: + type: string + description: The event name that was tracked, if event_name was used instead of + feature_id. + value: + type: number + description: The amount of usage that was recorded. + balance: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: The updated balance for the tracked feature. Null if tracking by + event_name that affects multiple features. + balances: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: Map of feature_id to updated balance for the tracked feature and + any related features (e.g. linked credit systems). Value + is null when the customer has no balance for that feature. + deductions: + type: array + items: + type: object + properties: + balance_id: + type: string + description: ID of the underlying balance row that was deducted from + (customer_entitlement or rollover). + feature_id: + type: string + description: The feature this balance belongs to. + plan_id: + anyOf: + - type: string + - type: "null" + description: ID of the plan/product this balance belongs to. Null when the + balance can't be attributed to a single plan (e.g. + it spans multiple). + reset: + anyOf: + - type: object + properties: + interval: + anyOf: + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + - const: multiple + description: The reset interval (hour, day, week, month, etc.) or 'multiple' if + combined from different intervals. + interval_count: + type: number + description: Number of intervals between resets (eg. 2 for bi-monthly). + resets_at: + anyOf: + - type: number + - type: "null" + description: Timestamp when the balance will next reset. + required: + - interval + - resets_at + - type: "null" + description: Reset configuration for the balance this deduction came from, or + null if the balance doesn't reset. + value: + type: number + description: Amount deducted from this balance. Positive when usage was + consumed, negative when credit was restored (e.g. a + refund via negative track value). + required: + - balance_id + - feature_id + - plan_id + - reset + - value + description: Per-balance breakdown of what this event deducted. A single event + can consume from multiple balance rows when credit systems + or rollovers are involved; this surfaces each one so + callers can build per-feature usage views without polling. + required: + - customer_id + - value + - balance + examples: + - &a59 + customer_id: cus_123 + value: 0.006 + balance: + feature_id: ai_credits + granted: 10 + remaining: 9.994 + usage: 0.006 + unlimited: false + overage_allowed: false + max_purchase: null + next_reset_at: 1773851121437 + breakdown: + - id: cus_ent_39qmLooixXLAqMywgXywjAz96rV + plan_id: pro_plan + included_grant: 100 + prepaid_grant: 0 + remaining: 72 + usage: 28 + unlimited: false + reset: + interval: month + resets_at: 1773851121437 + price: null + expires_at: null + deductions: + - balance_id: cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2 + feature_id: ai_credits + plan_id: pro + reset: + interval: month + resets_at: 1781288736881 + value: 0.006 + example: *a59 + x-speakeasy-name-override: trackTokens + parameters: + - *a5 /v1/balances.batch_track: post: operationId: batchTrack @@ -15788,14 +17351,14 @@ paths: - customer_id title: BatchTrackParams examples: - - &a57 + - &a60 - customer_id: cus_123 feature_id: messages value: 1 - customer_id: cus_123 event_name: message.sent value: 1 - example: *a57 + example: *a60 responses: "202": description: "Batch accepted. All items passed synchronous validation. Enqueue @@ -15813,9 +17376,9 @@ paths: required: - success examples: - - &a58 + - &a61 success: true - example: *a58 + example: *a61 x-speakeasy-name-override: batchTrack parameters: - *a5 @@ -15873,7 +17436,7 @@ paths: description: Filter events by time range title: EventsListParams examples: - - &a59 + - &a62 start_cursor: "" customer_id: cus_123 limit: 50 @@ -15882,7 +17445,7 @@ paths: custom_range: start: 1704067200000 end: 1706745600000 - example: *a59 + example: *a62 responses: "200": description: OK @@ -16007,7 +17570,7 @@ paths: - list - next_cursor examples: - - &a60 + - &a63 list: - id: evt_36xpk2TmuQX5zVPPQ8tCtnR5Weg timestamp: 1765958215459 @@ -16031,7 +17594,7 @@ paths: properties: {} deductions: null next_cursor: eyJ2IjowLCJpZCI6ImV2dF8zNnhtSHh4akFrcXh1ZkRmOXlIQVBOZlJyTE0iLCJ0IjoxNzY1OTU2NTEyMDU3fQ - example: *a60 + example: *a63 x-speakeasy-name-override: list parameters: - *a5 @@ -16124,7 +17687,7 @@ paths: - feature_id title: EventsAggregateParams examples: - - &a61 + - &a64 customer_id: cus_123 feature_id: api_calls range: 30d @@ -16135,7 +17698,7 @@ paths: - messages range: 7d group_by: properties.model - example: *a61 + example: *a64 responses: "200": description: OK @@ -16197,7 +17760,7 @@ paths: - list - total examples: - - &a62 + - &a65 list: - period: 1762905600000 values: @@ -16244,7 +17807,7 @@ paths: sessions: count: 2 sum: 15 - example: *a62 + example: *a65 x-speakeasy-name-override: aggregate parameters: - *a5 @@ -16364,12 +17927,12 @@ paths: - entity_id title: CreateEntityParams examples: - - &a63 + - &a66 customer_id: cus_123 entity_id: seat_42 feature_id: seats name: Seat 42 - example: *a63 + example: *a66 responses: "200": description: OK @@ -16571,10 +18134,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -16602,6 +18167,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -16765,7 +18369,7 @@ paths: - balances - flags examples: - - &a64 + - &a67 id: seat_42 name: Seat 42 customer_id: cus_123 @@ -16810,7 +18414,7 @@ paths: price: null expires_at: null invoices: [] - example: *a64 + example: *a67 x-speakeasy-name-override: create parameters: - *a5 @@ -16842,11 +18446,11 @@ paths: - entity_id title: GetEntityParams examples: - - &a65 + - &a68 entity_id: seat_42 - customer_id: cus_123 entity_id: seat_42 - example: *a65 + example: *a68 responses: "200": description: OK @@ -17048,10 +18652,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -17079,6 +18685,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -17242,7 +18887,7 @@ paths: - balances - flags examples: - - &a66 + - &a69 id: seat_42 name: Seat 42 customer_id: cus_123 @@ -17287,7 +18932,7 @@ paths: price: null expires_at: null invoices: [] - example: *a66 + example: *a69 x-speakeasy-name-override: get parameters: - *a5 @@ -17366,12 +19011,12 @@ paths: paginated call instead of iterating entities.get. title: ListEntitiesParams examples: - - &a67 + - &a70 start_cursor: "" limit: 10 - plans: - id: pro_plan - example: *a67 + example: *a70 responses: "200": description: OK @@ -17578,10 +19223,13 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' - for unified credit pools." + for unified credit pools, + 'ai_credit_system' for model-based token + pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -17609,6 +19257,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -17782,7 +19469,7 @@ paths: - list - next_cursor examples: - - &a68 + - &a71 list: - id: seat_42 name: Seat 42 @@ -17829,7 +19516,7 @@ paths: expires_at: null invoices: [] next_cursor: null - example: *a68 + example: *a71 x-speakeasy-name-override: list parameters: - *a5 @@ -17934,7 +19621,7 @@ paths: - entity_id title: UpdateEntityParams examples: - - &a69 + - &a72 customer_id: cus_123 entity_id: seat_42 billing_controls: @@ -17942,7 +19629,7 @@ paths: - feature_id: messages enabled: true overage_limit: 25 - example: *a69 + example: *a72 responses: "200": description: OK @@ -18144,10 +19831,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -18175,6 +19864,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -18338,7 +20066,7 @@ paths: - balances - flags examples: - - &a70 + - &a73 id: seat_42 name: Seat 42 customer_id: cus_123 @@ -18383,7 +20111,7 @@ paths: price: null expires_at: null invoices: [] - example: *a70 + example: *a73 x-speakeasy-name-override: update parameters: - *a5 @@ -18415,10 +20143,10 @@ paths: - entity_id title: DeleteEntityParams examples: - - &a71 + - &a74 customer_id: cus_123 entity_id: seat_42 - example: *a71 + example: *a74 responses: "200": description: OK @@ -18432,9 +20160,9 @@ paths: required: - success examples: - - &a72 + - &a75 success: true - example: *a72 + example: *a75 x-speakeasy-name-override: delete parameters: - *a5 @@ -18462,10 +20190,10 @@ paths: - program_id title: CreateReferralCodeParams examples: - - &a73 + - &a76 customer_id: cus_123 program_id: prog_123 - example: *a73 + example: *a76 responses: "200": description: OK @@ -18488,11 +20216,11 @@ paths: - customer_id - created_at examples: - - &a74 + - &a77 code: customer_id: created_at: 123 - example: *a74 + example: *a77 x-speakeasy-name-override: createCode parameters: - *a5 @@ -18520,10 +20248,10 @@ paths: - customer_id title: RedeemReferralCodeParams examples: - - &a75 + - &a78 code: REF123 customer_id: cus_456 - example: *a75 + example: *a78 responses: "200": description: OK @@ -18546,11 +20274,11 @@ paths: - customer_id - reward_id examples: - - &a76 + - &a79 id: customer_id: reward_id: - example: *a76 + example: *a79 x-speakeasy-name-override: redeemCode parameters: - *a5 @@ -18578,10 +20306,10 @@ paths: - customer_id title: RedeemRewardCodeParams examples: - - &a77 + - &a80 code: REWARD10 customer_id: cus_456 - example: *a77 + example: *a80 responses: "200": description: OK @@ -18612,12 +20340,12 @@ paths: - reward_id - entitlements_granted examples: - - &a78 + - &a81 reward_id: reward_789 entitlements_granted: - feature_id: messages balance: 100 - example: *a78 + example: *a81 x-speakeasy-name-override: redeemCode parameters: - *a5 @@ -18656,12 +20384,12 @@ paths: - redirect_url title: LinkRevenueCatParams examples: - - &a77 + - &a82 organization_slug: acme env: test project_name: acme-mobile redirect_url: https://dashboard.useautumn.com/dev?tab=revenuecat - example: *a77 + example: *a82 responses: "200": description: OK @@ -18676,9 +20404,9 @@ paths: - oauth_url title: LinkRevenueCatResponse examples: - - &a78 + - &a83 oauth_url: https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write - example: *a78 + example: *a83 x-speakeasy-name-override: linkRevenueCat parameters: - *a5 @@ -18718,13 +20446,13 @@ paths: - env title: SyncRevenueCatParams examples: - - &a79 + - &a84 organization_slug: acme env: test product_ids: - pro - premium - example: *a79 + example: *a84 responses: "200": description: OK @@ -18790,7 +20518,7 @@ paths: - results title: SyncRevenueCatResponse examples: - - &a80 + - &a85 results: - plan_id: pro status: synced @@ -18801,7 +20529,7 @@ paths: product: created store_push: skipped price: set - example: *a80 + example: *a85 x-speakeasy-name-override: syncRevenueCat parameters: - *a5 @@ -18835,10 +20563,10 @@ paths: - env title: GetRevenueCatKeysParams examples: - - &a81 + - &a86 organization_slug: acme env: test - example: *a81 + example: *a86 responses: "200": description: OK @@ -18901,7 +20629,7 @@ paths: - oauth_access_token title: GetRevenueCatKeysResponse examples: - - &a82 + - &a87 apps: - app_id: app1a2b3c4d app_type: test_store @@ -18912,7 +20640,7 @@ paths: environment: production app_id: app1a2b3c4 oauth_access_token: atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ - example: *a82 + example: *a87 x-speakeasy-name-override: getRevenueCatKeys parameters: - *a5 @@ -19388,9 +21116,276 @@ webhooks: feature_id: description: The ID of the feature that was added or removed. type: string + item: + description: The item snapshot that was added or removed. + type: object + properties: + feature_id: + description: The ID of the feature this item configures. + type: string + feature: + description: The full feature object if expanded. + type: object + properties: + id: + description: The ID of the feature, used to refer to it in other API calls like + /track or /check. + type: string + name: + description: The name of the feature. + anyOf: + - type: string + - type: "null" + type: + description: The type of the feature + type: string + enum: + - static + - boolean + - single_use + - continuous_use + - credit_system + - ai_credit_system + display: + description: Singular and plural display names for the feature. + anyOf: + - type: object + properties: + singular: + description: The singular display name for the feature. + type: string + plural: + description: The plural display name for the feature. + type: string + required: + - singular + - plural + additionalProperties: false + - type: "null" + credit_schema: + description: Credit cost schema for credit system features. + anyOf: + - type: array + items: + type: object + properties: + metered_feature_id: + description: The ID of the metered feature (should be a single_use feature). + type: string + credit_cost: + description: The credit cost of the metered feature. + type: number + required: + - metered_feature_id + - credit_cost + additionalProperties: false + - type: "null" + archived: + description: Whether or not the feature is archived. + anyOf: + - type: boolean + - type: "null" + required: + - id + - type + additionalProperties: false + included: + description: Number of free units included. For consumable features, balance + resets to this number each interval. + type: number + unlimited: + description: Whether the customer has unlimited access to this feature. + type: boolean + reset: + description: Reset configuration for consumable features. Null for + non-consumable features like seats where + usage persists across billing cycles. + anyOf: + - type: object + properties: + interval: + description: The interval at which the feature balance resets (e.g. 'month', + 'year'). For consumable + features, usage resets to 0 and + included units are restored. + type: string + enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + interval_count: + description: Number of intervals between resets. Defaults to 1. + type: number + required: + - interval + additionalProperties: false + - type: "null" + price: + description: Pricing configuration for usage beyond included units. Null if + feature is entirely free. + anyOf: + - type: object + properties: + amount: + description: Price per billing_units after included usage is consumed. Mutually + exclusive with tiers. + type: number + tiers: + description: Tiered pricing configuration. Each tier's 'to' INCLUDES the + included amount. Either 'tiers' + or 'amount' is required. + type: array + items: + type: object + properties: + to: + anyOf: + - type: number + - type: string + const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount + additionalProperties: false + tier_behavior: + type: string + enum: + - graduated + - volume + interval: + description: Billing interval for this price. For consumable features, should + match reset.interval. + type: string + enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + interval_count: + description: Number of intervals per billing cycle. Defaults to 1. + type: number + billing_units: + description: Number of units per price increment. Usage is rounded UP to the + nearest billing_units when + billed (e.g. billing_units=100 + means 101 usage rounds to 200). + type: number + billing_method: + description: "'prepaid' for features like seats where customers pay upfront, + 'usage_based' for pay-as-you-go + after included usage." + type: string + enum: + - prepaid + - usage_based + max_purchase: + description: Maximum units a customer can purchase beyond included. E.g. if + included=100 and + max_purchase=300, customer can + use up to 400 total before usage + is capped. Null for no limit. + anyOf: + - type: number + - type: "null" + required: + - interval + - billing_units + - billing_method + - max_purchase + additionalProperties: false + - type: "null" + display: + description: Display text for showing this item in pricing pages. + type: object + properties: + primary_text: + description: Main display text (e.g. '$10' or '100 messages'). + type: string + secondary_text: + description: Secondary display text (e.g. 'per month' or 'then $0.5 per 100'). + type: string + required: + - primary_text + additionalProperties: false + rollover: + description: Rollover configuration for unused units. If set, unused included + units roll over to the next period. + type: object + properties: + max: + description: Maximum rollover units. Null for unlimited rollover. + anyOf: + - type: number + - type: "null" + max_percentage: + description: Maximum rollover as a percentage (0-100) of included + prepaid + grant. Mutually exclusive with max. + anyOf: + - type: number + - type: "null" + expiry_duration_type: + description: When rolled over units expire. + type: string + enum: + - month + - forever + expiry_duration_length: + description: Number of periods before expiry. + type: number + required: + - max + - expiry_duration_type + additionalProperties: false + proration: + internal: true + type: object + properties: + on_increase: + description: How to handle billing when quantity increases mid-cycle (prepaid + features only). + type: string + enum: + - bill_immediately + - prorate_immediately + - prorate_next_cycle + - bill_next_cycle + on_decrease: + description: How to handle credits when quantity decreases mid-cycle (prepaid + features only). + type: string + enum: + - prorate + - prorate_immediately + - prorate_next_cycle + - none + - no_prorations + additionalProperties: false + entity_feature_id: + internal: true + type: string + required: + - feature_id + - included + - unlimited + - reset + - price + additionalProperties: false required: - action - feature_id + - item additionalProperties: false required: - action diff --git a/packages/openapi/openapi.yml b/packages/openapi/openapi.yml index ab68de215..cfbc8ca4f 100644 --- a/packages/openapi/openapi.yml +++ b/packages/openapi/openapi.yml @@ -586,10 +586,11 @@ components: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit - pools." + pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -617,6 +618,45 @@ components: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -1088,6 +1128,7 @@ components: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -1177,9 +1218,19 @@ components: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. @@ -1409,10 +1460,11 @@ components: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit - pools." + pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -1440,6 +1492,45 @@ components: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -1566,9 +1657,19 @@ components: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration if applicable. tier_behavior: enum: @@ -2397,10 +2498,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -2428,6 +2531,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -3268,10 +3410,13 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' - for unified credit pools." + for unified credit pools, + 'ai_credit_system' for model-based token + pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -3299,6 +3444,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -4117,10 +4301,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -4148,6 +4334,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -4593,9 +4818,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -4842,6 +5070,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -4931,9 +5160,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. @@ -5363,6 +5602,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -5452,9 +5692,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. @@ -5862,6 +6112,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -5952,9 +6203,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. @@ -6426,9 +6687,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -6546,6 +6810,8 @@ paths: pattern: ^[a-zA-Z0-9_-]+$ description: The new ID to use for the plan. Can only be updated if the plan has not been used by any customers. + disable_version: + type: boolean required: - plan_id title: UpdatePlanParams @@ -6661,6 +6927,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -6750,9 +7017,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. @@ -7153,7 +7430,19 @@ paths: your user interface. (optional) @param creditSchema - A schema that maps 'single_use' feature IDs to - credit costs. Applicable only for 'credit_system' features. (optional) + credit costs. For classic credit systems only — AI credit systems use + model_markups instead. (optional) + + @param modelMarkups - Per-model markup overrides for AI credit systems. + Maps model IDs to their markup configuration. (optional) + + @param defaultMarkup - Default percentage markup for this AI credit + system. Used when no model or provider markup applies. Use -100 to make + usage free. (optional) + + @param providerMarkups - Per-provider default markup percentages for AI + credit systems. Provider keys match the first segment of model_id. + (optional) @param featureId - The ID of the feature to create. @@ -7176,6 +7465,7 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features @@ -7212,8 +7502,51 @@ paths: required: - metered_feature_id - credit_cost - description: A schema that maps 'single_use' feature IDs to credit costs. - Applicable only for 'credit_system' features. + description: A schema that maps 'single_use' feature IDs to credit costs. For + classic credit systems only — AI credit systems use + model_markups instead. + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. Maps model IDs to + their markup configuration. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for this AI credit system. Used when no + model or provider markup applies. Use -100 to make usage + free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. + Provider keys match the first segment of model_id. event_names: type: array items: @@ -7260,10 +7593,11 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit - pools." + pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -7291,6 +7625,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -7381,10 +7754,11 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit - pools." + pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -7412,6 +7786,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -7487,10 +7900,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified - credit pools." + credit pools, 'ai_credit_system' for model-based + token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -7518,6 +7933,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -7623,7 +8077,19 @@ paths: your user interface. (optional) @param creditSchema - A schema that maps 'single_use' feature IDs to - credit costs. Applicable only for 'credit_system' features. (optional) + credit costs. For classic credit systems only — AI credit systems use + model_markups instead. (optional) + + @param modelMarkups - Per-model markup overrides for AI credit systems. + Maps model IDs to their markup configuration. (optional) + + @param defaultMarkup - Default percentage markup for this AI credit + system. Used when no model or provider markup applies. Use -100 to make + usage free. (optional) + + @param providerMarkups - Per-provider default markup percentages for AI + credit systems. Provider keys match the first segment of model_id. + (optional) @param archived - Whether the feature is archived. Archived features are hidden from the dashboard. (optional) @@ -7653,6 +8119,7 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features @@ -7689,8 +8156,51 @@ paths: required: - metered_feature_id - credit_cost - description: A schema that maps 'single_use' feature IDs to credit costs. - Applicable only for 'credit_system' features. + description: A schema that maps 'single_use' feature IDs to credit costs. For + classic credit systems only — AI credit systems use + model_markups instead. + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. Maps model IDs to + their markup configuration. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for this AI credit system. Used when no + model or provider markup applies. Use -100 to make usage + free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. + Provider keys match the first segment of model_id. event_names: type: array items: @@ -7737,10 +8247,11 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit - pools." + pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -7768,6 +8279,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -8179,9 +8729,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -8241,7 +8794,7 @@ paths: pricing, and rollover settings. description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items - / update_items. + / deprecated update_items. add_items: type: array items: @@ -8337,9 +8890,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -8413,15 +8969,37 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). @@ -8745,7 +9323,7 @@ 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":1780584084429,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781793684429,"plans":[{"planId":"pro_plan"}]}] }); + const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1781115250101,"plans":[{"planId":"trial_plan"}]},{"startsAt":1782324850101,"plans":[{"planId":"pro_plan"}]}] }); ``` @param customerId - The ID of the customer to create the schedule for. @@ -9023,10 +9601,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 - total. + total. Null for no limit. required: - interval - billing_method @@ -9085,10 +9665,219 @@ paths: title: PlanItem description: Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. - description: Override the items in the plan. - additionalProperties: false - description: Customize the plan to schedule. Can override the price, items, or - both. + 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: + type: array + items: + type: object + properties: + feature_id: + type: string + description: The ID of the feature to configure. + included: + type: number + description: Number of free units included. Balance resets to this each interval + for consumable features. + unlimited: + type: boolean + description: If true, customer has unlimited access to this feature. + reset: + type: object + properties: + interval: + enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + description: Interval at which balance resets (e.g. 'month', 'year'). For + consumable features only. + interval_count: + type: number + description: Number of intervals between resets. Defaults to 1. + required: + - interval + description: Reset configuration for consumable features. Omit for + non-consumable features like seats. + price: + type: object + properties: + amount: + type: number + description: Price per billing_units after included usage. Either 'amount' or + 'tiers' is required. + tiers: + type: array + items: + type: object + properties: + to: {} + amount: {} + flat_amount: {} + description: Tiered pricing. Either 'amount' or 'tiers' is required. + tier_behavior: + enum: + - graduated + - volume + type: string + interval: + enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + description: Billing interval. For consumable features, should match + reset.interval. + interval_count: + type: number + default: 1 + description: Number of intervals per billing cycle. Defaults to 1. + billing_units: + type: number + default: 1 + description: Units per price increment. Usage is rounded UP when billed (e.g. + billing_units=100 means 101 + rounds to 200). + billing_method: + enum: + - prepaid + - usage_based + type: string + description: "'prepaid' for upfront payment (seats), 'usage_based' for + pay-as-you-go." + max_purchase: + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, + max_purchase=300 allows 400 + total. Null for no limit. + required: + - interval + - billing_method + description: Pricing for usage beyond included units. Omit for free features. + proration: + type: object + properties: + on_increase: + enum: + - bill_immediately + - prorate_immediately + - prorate_next_cycle + - bill_next_cycle + type: string + description: Billing behavior when quantity increases mid-cycle. + on_decrease: + enum: + - prorate + - prorate_immediately + - prorate_next_cycle + - none + - no_prorations + type: string + description: Credit behavior when quantity decreases mid-cycle. + required: + - on_increase + - on_decrease + description: Proration settings for prepaid features. Controls mid-cycle + quantity change billing. + rollover: + type: object + properties: + max: + type: number + description: Max rollover units. Omit for unlimited rollover. + max_percentage: + type: number + description: Maximum rollover as a percentage (0-100) of included + prepaid + grant. Mutually exclusive with + max. + expiry_duration_type: + enum: + - month + - forever + type: string + description: When rolled over units expire. + expiry_duration_length: + type: number + description: Number of periods before expiry. + required: + - expiry_duration_type + description: Rollover config for unused units. If set, unused included units + carry over. + required: + - feature_id + title: PlanItem + description: Configuration for a feature item in a plan, including usage limits, + pricing, and rollover settings. + description: Items to add to the plan. + remove_items: + type: array + items: + type: object + properties: + feature_id: + type: string + description: Match items linked to this feature. + billing_method: + enum: + - prepaid + - usage_based + type: string + description: Match items with this billing method (prepaid or usage_based). + interval: + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in + count. + title: PlanItemFilter + description: Filter for matching plan items. All provided fields must match + (AND). + description: Filters selecting items to remove from the plan. + description: Customize the plan to schedule. Can override price, replace items, + or patch items with add_items, remove_items, + and update_items. subscription_id: type: string description: A unique ID to identify this subscription. Useful when scheduling @@ -9257,9 +10046,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + Null for no limit. required: - interval - billing_method @@ -9318,10 +10110,218 @@ paths: title: PlanItem description: Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. - description: Override the items in the plan. - additionalProperties: false - description: Customize the plan to schedule. Can override the price, items, or - both. + 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: + type: array + items: + type: object + properties: + feature_id: + type: string + description: The ID of the feature to configure. + included: + type: number + description: Number of free units included. Balance resets to this each interval + for consumable features. + unlimited: + type: boolean + description: If true, customer has unlimited access to this feature. + reset: + type: object + properties: + interval: + enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + description: Interval at which balance resets (e.g. 'month', 'year'). For + consumable features only. + interval_count: + type: number + description: Number of intervals between resets. Defaults to 1. + required: + - interval + description: Reset configuration for consumable features. Omit for + non-consumable features like seats. + price: + type: object + properties: + amount: + type: number + description: Price per billing_units after included usage. Either 'amount' or + 'tiers' is required. + tiers: + type: array + items: + type: object + properties: + to: {} + amount: {} + flat_amount: {} + description: Tiered pricing. Either 'amount' or 'tiers' is required. + tier_behavior: + enum: + - graduated + - volume + type: string + interval: + enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + description: Billing interval. For consumable features, should match + reset.interval. + interval_count: + type: number + default: 1 + description: Number of intervals per billing cycle. Defaults to 1. + billing_units: + type: number + default: 1 + description: Units per price increment. Usage is rounded UP when billed (e.g. + billing_units=100 means 101 rounds + to 200). + billing_method: + enum: + - prepaid + - usage_based + type: string + description: "'prepaid' for upfront payment (seats), 'usage_based' for + pay-as-you-go." + max_purchase: + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, + max_purchase=300 allows 400 total. + Null for no limit. + required: + - interval + - billing_method + description: Pricing for usage beyond included units. Omit for free features. + proration: + type: object + properties: + on_increase: + enum: + - bill_immediately + - prorate_immediately + - prorate_next_cycle + - bill_next_cycle + type: string + description: Billing behavior when quantity increases mid-cycle. + on_decrease: + enum: + - prorate + - prorate_immediately + - prorate_next_cycle + - none + - no_prorations + type: string + description: Credit behavior when quantity decreases mid-cycle. + required: + - on_increase + - on_decrease + description: Proration settings for prepaid features. Controls mid-cycle + quantity change billing. + rollover: + type: object + properties: + max: + type: number + description: Max rollover units. Omit for unlimited rollover. + max_percentage: + type: number + description: Maximum rollover as a percentage (0-100) of included + prepaid + grant. Mutually exclusive with + max. + expiry_duration_type: + enum: + - month + - forever + type: string + description: When rolled over units expire. + expiry_duration_length: + type: number + description: Number of periods before expiry. + required: + - expiry_duration_type + description: Rollover config for unused units. If set, unused included units + carry over. + required: + - feature_id + title: PlanItem + description: Configuration for a feature item in a plan, including usage limits, + pricing, and rollover settings. + description: Items to add to the plan. + remove_items: + type: array + items: + type: object + properties: + feature_id: + type: string + description: Match items linked to this feature. + billing_method: + enum: + - prepaid + - usage_based + type: string + description: Match items with this billing method (prepaid or usage_based). + interval: + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in + count. + title: PlanItemFilter + description: Filter for matching plan items. All provided fields must match + (AND). + description: Filters selecting items to remove from the plan. + description: Customize the plan to schedule. Can override price, replace items, + or patch items with add_items, remove_items, and + update_items. subscription_id: type: string description: A unique ID to identify this subscription. Useful when scheduling @@ -9667,9 +10667,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null + for no limit. required: - interval - billing_method @@ -10342,9 +11345,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -10404,7 +11410,7 @@ paths: pricing, and rollover settings. description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items - / update_items. + / deprecated update_items. add_items: type: array items: @@ -10500,9 +11506,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -10576,15 +11585,37 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). @@ -11405,9 +12436,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null + for no limit. required: - interval - billing_method @@ -12390,9 +13424,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -12452,7 +13489,7 @@ paths: pricing, and rollover settings. description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items - / update_items. + / deprecated update_items. add_items: type: array items: @@ -12548,9 +13585,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -12624,15 +13664,37 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). @@ -13116,9 +14178,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -13178,7 +14243,7 @@ paths: pricing, and rollover settings. description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items - / update_items. + / deprecated update_items. add_items: type: array items: @@ -13274,9 +14339,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -13350,15 +14418,37 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). @@ -14142,9 +15232,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -14204,7 +15297,7 @@ paths: pricing, and rollover settings. description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items - / update_items. + / deprecated update_items. add_items: type: array items: @@ -14300,9 +15393,12 @@ paths: description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number + anyOf: + - type: number + - type: "null" description: Max units purchasable beyond included. E.g. included=100, - max_purchase=300 allows 400 total. + max_purchase=300 allows 400 total. Null for no + limit. required: - interval - billing_method @@ -14376,15 +15472,37 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items + that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). @@ -15137,10 +16255,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -15168,6 +16288,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -15684,10 +16843,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -15715,6 +16876,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -16575,6 +17775,448 @@ paths: x-speakeasy-name-override: track parameters: - *a1 + /v1/balances.track_tokens: + post: + operationId: trackTokens + description: >- + Records AI token usage for a customer and returns the updated AI credit + balance. + + + Use this after an LLM request when you have input and output token + counts. Autumn converts token usage to a dollar amount using the + configured model pricing and markup, then tracks that value against the + customer's AI credit system. + + + @example + + ```typescript + + // Track one LLM response + + const response = await client.trackTokens({ + customerId: "cus_123", + featureId: "ai_credits", + modelId: "anthropic/claude-sonnet-4-20250514", + inputTokens: 1000, + outputTokens: 500, + }); + + ``` + + + @param customerId - The ID of the customer. + + @param entityId - The ID of the entity for entity-scoped balances. + (optional) + + @param featureId - The ID of the AI credit system feature. Auto-detected + from the customer's entitlements if omitted — only required when a + customer has multiple AI credit systems. (optional) + + @param modelId - The AI model as '/' (e.g. + 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). The provider + is the first path segment and must match a provider + model key in + models.dev. + + @param inputTokens - Number of non-cached text input tokens consumed. + Exclusive of cache and audio token pools. + + @param outputTokens - Number of text output tokens consumed. Exclusive + of the reasoning and audio output pools. + + @param cacheReadTokens - Number of cached input tokens read. (optional) + + @param cacheWriteTokens - Number of input tokens written to the cache. + (optional) + + @param audioInputTokens - Number of audio input tokens consumed. + (optional) + + @param audioOutputTokens - Number of audio output tokens generated. + (optional) + + @param reasoningTokens - Number of reasoning tokens generated. + (optional) + + @param properties - Additional properties to attach to this usage event. + (optional) + + + @returns The dollar value recorded and the updated AI credit system + balance. If Autumn is experiencing degraded service from a downstream + provider, the API may return 202 after accepting the token usage event + for replay so it can be tracked as soon as the service is restored. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + customer_id: + type: string + description: The ID of the customer. + entity_id: + type: string + description: The ID of the entity for entity-scoped balances. + feature_id: + type: string + description: The ID of the AI credit system feature. Auto-detected from the + customer's entitlements if omitted — only required when a + customer has multiple AI credit systems. + model_id: + type: string + description: The AI model as '/' (e.g. + 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). + The provider is the first path segment and must match a + provider + model key in models.dev. + input_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of non-cached text input tokens consumed. Exclusive of cache + and audio token pools. + output_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of text output tokens consumed. Exclusive of the reasoning + and audio output pools. + cache_read_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of cached input tokens read. + cache_write_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of input tokens written to the cache. + audio_input_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of audio input tokens consumed. + audio_output_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of audio output tokens generated. + reasoning_tokens: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Number of reasoning tokens generated. + properties: + type: object + propertyNames: + type: string + additionalProperties: {} + description: Additional properties to attach to this usage event. + required: + - customer_id + - model_id + - input_tokens + - output_tokens + title: TrackTokensParams + examples: + - customer_id: cus_123 + feature_id: ai_credits + model_id: anthropic/claude-sonnet-4-20250514 + input_tokens: 1000 + output_tokens: 500 + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + customer_id: + type: string + description: The ID of the customer whose usage was tracked. + entity_id: + type: string + description: The ID of the entity, if entity-scoped tracking was performed. + event_name: + type: string + description: The event name that was tracked, if event_name was used instead of + feature_id. + value: + type: number + description: The amount of usage that was recorded. + balance: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: The updated balance for the tracked feature. Null if tracking by + event_name that affects multiple features. + balances: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: Map of feature_id to updated balance for the tracked feature and + any related features (e.g. linked credit systems). Value + is null when the customer has no balance for that feature. + deductions: + type: array + items: + type: object + properties: + balance_id: + type: string + description: ID of the underlying balance row that was deducted from + (customer_entitlement or rollover). + feature_id: + type: string + description: The feature this balance belongs to. + plan_id: + anyOf: + - type: string + - type: "null" + description: ID of the plan/product this balance belongs to. Null when the + balance can't be attributed to a single plan (e.g. + it spans multiple). + reset: + anyOf: + - type: object + properties: + interval: + anyOf: + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + - const: multiple + description: The reset interval (hour, day, week, month, etc.) or 'multiple' if + combined from different intervals. + interval_count: + type: number + description: Number of intervals between resets (eg. 2 for bi-monthly). + resets_at: + anyOf: + - type: number + - type: "null" + description: Timestamp when the balance will next reset. + required: + - interval + - resets_at + - type: "null" + description: Reset configuration for the balance this deduction came from, or + null if the balance doesn't reset. + value: + type: number + description: Amount deducted from this balance. Positive when usage was + consumed, negative when credit was restored (e.g. a + refund via negative track value). + required: + - balance_id + - feature_id + - plan_id + - reset + - value + description: Per-balance breakdown of what this event deducted. A single event + can consume from multiple balance rows when credit systems + or rollovers are involved; this surfaces each one so + callers can build per-feature usage views without polling. + required: + - customer_id + - value + - balance + examples: + - customer_id: cus_123 + value: 0.006 + balance: + feature_id: ai_credits + granted: 10 + remaining: 9.994 + usage: 0.006 + unlimited: false + overage_allowed: false + max_purchase: null + next_reset_at: 1773851121437 + breakdown: + - id: cus_ent_39qmLooixXLAqMywgXywjAz96rV + plan_id: pro_plan + included_grant: 100 + prepaid_grant: 0 + remaining: 72 + usage: 28 + unlimited: false + reset: + interval: month + resets_at: 1773851121437 + price: null + expires_at: null + deductions: + - balance_id: cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2 + feature_id: ai_credits + plan_id: pro + reset: + interval: month + resets_at: 1781288736881 + value: 0.006 + "202": + description: Accepted. Autumn is experiencing degraded service from a downstream + provider, so the token usage event was accepted for replay and will + be tracked as soon as the service is restored. + content: + application/json: + schema: + type: object + properties: + customer_id: + type: string + description: The ID of the customer whose usage was tracked. + entity_id: + type: string + description: The ID of the entity, if entity-scoped tracking was performed. + event_name: + type: string + description: The event name that was tracked, if event_name was used instead of + feature_id. + value: + type: number + description: The amount of usage that was recorded. + balance: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: The updated balance for the tracked feature. Null if tracking by + event_name that affects multiple features. + balances: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: Map of feature_id to updated balance for the tracked feature and + any related features (e.g. linked credit systems). Value + is null when the customer has no balance for that feature. + deductions: + type: array + items: + type: object + properties: + balance_id: + type: string + description: ID of the underlying balance row that was deducted from + (customer_entitlement or rollover). + feature_id: + type: string + description: The feature this balance belongs to. + plan_id: + anyOf: + - type: string + - type: "null" + description: ID of the plan/product this balance belongs to. Null when the + balance can't be attributed to a single plan (e.g. + it spans multiple). + reset: + anyOf: + - type: object + properties: + interval: + anyOf: + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + - const: multiple + description: The reset interval (hour, day, week, month, etc.) or 'multiple' if + combined from different intervals. + interval_count: + type: number + description: Number of intervals between resets (eg. 2 for bi-monthly). + resets_at: + anyOf: + - type: number + - type: "null" + description: Timestamp when the balance will next reset. + required: + - interval + - resets_at + - type: "null" + description: Reset configuration for the balance this deduction came from, or + null if the balance doesn't reset. + value: + type: number + description: Amount deducted from this balance. Positive when usage was + consumed, negative when credit was restored (e.g. a + refund via negative track value). + required: + - balance_id + - feature_id + - plan_id + - reset + - value + description: Per-balance breakdown of what this event deducted. A single event + can consume from multiple balance rows when credit systems + or rollovers are involved; this surfaces each one so + callers can build per-feature usage views without polling. + required: + - customer_id + - value + - balance + examples: + - customer_id: cus_123 + value: 0.006 + balance: + feature_id: ai_credits + granted: 10 + remaining: 9.994 + usage: 0.006 + unlimited: false + overage_allowed: false + max_purchase: null + next_reset_at: 1773851121437 + breakdown: + - id: cus_ent_39qmLooixXLAqMywgXywjAz96rV + plan_id: pro_plan + included_grant: 100 + prepaid_grant: 0 + remaining: 72 + usage: 28 + unlimited: false + reset: + interval: month + resets_at: 1773851121437 + price: null + expires_at: null + deductions: + - balance_id: cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2 + feature_id: ai_credits + plan_id: pro + reset: + interval: month + resets_at: 1781288736881 + value: 0.006 + x-speakeasy-name-override: trackTokens + parameters: + - *a1 /v1/balances.batch_track: post: operationId: batchTrack @@ -17454,10 +19096,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -17485,6 +19129,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -17960,10 +19643,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -17991,6 +19676,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -18536,10 +20260,13 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' - for unified credit pools." + for unified credit pools, + 'ai_credit_system' for model-based token + pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -18567,6 +20294,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -19108,10 +20874,12 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for - unified credit pools." + unified credit pools, 'ai_credit_system' for + model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API @@ -19139,6 +20907,45 @@ paths: - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make + usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -20341,9 +22148,276 @@ webhooks: feature_id: description: The ID of the feature that was added or removed. type: string + item: + description: The item snapshot that was added or removed. + type: object + properties: + feature_id: + description: The ID of the feature this item configures. + type: string + feature: + description: The full feature object if expanded. + type: object + properties: + id: + description: The ID of the feature, used to refer to it in other API calls like + /track or /check. + type: string + name: + description: The name of the feature. + anyOf: + - type: string + - type: "null" + type: + description: The type of the feature + type: string + enum: + - static + - boolean + - single_use + - continuous_use + - credit_system + - ai_credit_system + display: + description: Singular and plural display names for the feature. + anyOf: + - type: object + properties: + singular: + description: The singular display name for the feature. + type: string + plural: + description: The plural display name for the feature. + type: string + required: + - singular + - plural + additionalProperties: false + - type: "null" + credit_schema: + description: Credit cost schema for credit system features. + anyOf: + - type: array + items: + type: object + properties: + metered_feature_id: + description: The ID of the metered feature (should be a single_use feature). + type: string + credit_cost: + description: The credit cost of the metered feature. + type: number + required: + - metered_feature_id + - credit_cost + additionalProperties: false + - type: "null" + archived: + description: Whether or not the feature is archived. + anyOf: + - type: boolean + - type: "null" + required: + - id + - type + additionalProperties: false + included: + description: Number of free units included. For consumable features, balance + resets to this number each interval. + type: number + unlimited: + description: Whether the customer has unlimited access to this feature. + type: boolean + reset: + description: Reset configuration for consumable features. Null for + non-consumable features like seats where + usage persists across billing cycles. + anyOf: + - type: object + properties: + interval: + description: The interval at which the feature balance resets (e.g. 'month', + 'year'). For consumable + features, usage resets to 0 and + included units are restored. + type: string + enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + interval_count: + description: Number of intervals between resets. Defaults to 1. + type: number + required: + - interval + additionalProperties: false + - type: "null" + price: + description: Pricing configuration for usage beyond included units. Null if + feature is entirely free. + anyOf: + - type: object + properties: + amount: + description: Price per billing_units after included usage is consumed. Mutually + exclusive with tiers. + type: number + tiers: + description: Tiered pricing configuration. Each tier's 'to' INCLUDES the + included amount. Either 'tiers' + or 'amount' is required. + type: array + items: + type: object + properties: + to: + anyOf: + - type: number + - type: string + const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount + additionalProperties: false + tier_behavior: + type: string + enum: + - graduated + - volume + interval: + description: Billing interval for this price. For consumable features, should + match reset.interval. + type: string + enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + interval_count: + description: Number of intervals per billing cycle. Defaults to 1. + type: number + billing_units: + description: Number of units per price increment. Usage is rounded UP to the + nearest billing_units when + billed (e.g. billing_units=100 + means 101 usage rounds to 200). + type: number + billing_method: + description: "'prepaid' for features like seats where customers pay upfront, + 'usage_based' for pay-as-you-go + after included usage." + type: string + enum: + - prepaid + - usage_based + max_purchase: + description: Maximum units a customer can purchase beyond included. E.g. if + included=100 and + max_purchase=300, customer can + use up to 400 total before usage + is capped. Null for no limit. + anyOf: + - type: number + - type: "null" + required: + - interval + - billing_units + - billing_method + - max_purchase + additionalProperties: false + - type: "null" + display: + description: Display text for showing this item in pricing pages. + type: object + properties: + primary_text: + description: Main display text (e.g. '$10' or '100 messages'). + type: string + secondary_text: + description: Secondary display text (e.g. 'per month' or 'then $0.5 per 100'). + type: string + required: + - primary_text + additionalProperties: false + rollover: + description: Rollover configuration for unused units. If set, unused included + units roll over to the next period. + type: object + properties: + max: + description: Maximum rollover units. Null for unlimited rollover. + anyOf: + - type: number + - type: "null" + max_percentage: + description: Maximum rollover as a percentage (0-100) of included + prepaid + grant. Mutually exclusive with max. + anyOf: + - type: number + - type: "null" + expiry_duration_type: + description: When rolled over units expire. + type: string + enum: + - month + - forever + expiry_duration_length: + description: Number of periods before expiry. + type: number + required: + - max + - expiry_duration_type + additionalProperties: false + proration: + internal: true + type: object + properties: + on_increase: + description: How to handle billing when quantity increases mid-cycle (prepaid + features only). + type: string + enum: + - bill_immediately + - prorate_immediately + - prorate_next_cycle + - bill_next_cycle + on_decrease: + description: How to handle credits when quantity decreases mid-cycle (prepaid + features only). + type: string + enum: + - prorate + - prorate_immediately + - prorate_next_cycle + - none + - no_prorations + additionalProperties: false + entity_feature_id: + internal: true + type: string + required: + - feature_id + - included + - unlimited + - reset + - price + additionalProperties: false required: - action - feature_id + - item additionalProperties: false required: - action 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/balancesContract.ts b/packages/openapi/v2.3/contracts/balancesContract.ts index 1248ab7de..a18a81560 100644 --- a/packages/openapi/v2.3/contracts/balancesContract.ts +++ b/packages/openapi/v2.3/contracts/balancesContract.ts @@ -9,6 +9,7 @@ import { FinalizeLockParamsV0Schema, TrackParamsSchema, TrackResponseV3Schema, + TrackTokensParamsSchema, UpdateBalanceParamsV0Schema, } from "@autumn/shared"; import { oc } from "@orpc/contract"; @@ -16,6 +17,7 @@ import { z } from "zod/v4"; import { balancesCheckJsDoc, balancesTrackJsDoc, + balancesTrackTokensJsDoc, } from "../jsDocs/balancesJsDocs"; type SpecWithResponses = { @@ -157,6 +159,63 @@ export const balancesTrackContract = oc }), ); +export const balancesTrackTokensContract = oc + .route({ + method: "POST", + path: "/v1/balances.track_tokens", + operationId: "trackTokens", + description: balancesTrackTokensJsDoc, + spec: (spec) => + withAcceptedResponse( + spec, + "trackTokens", + "Accepted. Autumn is experiencing degraded service from a downstream provider, so the token usage event was accepted for replay and will be tracked as soon as the service is restored.", + ), + }) + .input( + TrackTokensParamsSchema.meta({ + title: "TrackTokensParams", + examples: [ + { + customer_id: "cus_123", + feature_id: "ai_credits", + model_id: "anthropic/claude-sonnet-4-20250514", + input_tokens: 1000, + output_tokens: 500, + }, + ], + }), + ) + .output( + TrackResponseV3Schema.meta({ + examples: [ + { + customer_id: "cus_123", + value: 0.006, + balance: { + ...API_BALANCE_V1_EXAMPLE, + feature_id: "ai_credits", + granted: 10, + remaining: 9.994, + usage: 0.006, + }, + deductions: [ + { + balance_id: "cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2", + feature_id: "ai_credits", + plan_id: "pro", + reset: { + interval: "month", + resets_at: 1781288736881, + }, + value: 0.006, + }, + ], + }, + ], + }), + ); + export const balancesBatchTrackContract = oc .route({ method: "POST", diff --git a/packages/openapi/v2.3/contracts/index.ts b/packages/openapi/v2.3/contracts/index.ts index a4f3d70d2..78748b962 100644 --- a/packages/openapi/v2.3/contracts/index.ts +++ b/packages/openapi/v2.3/contracts/index.ts @@ -6,6 +6,7 @@ import { balancesDeleteContract, balancesFinalizeContract, balancesTrackContract, + balancesTrackTokensContract, balancesUpdateContract, } from "./balancesContract.js"; import { @@ -102,6 +103,7 @@ export const v2_3ContractRouter = oc.router({ balancesFinalize: balancesFinalizeContract, balancesCheck: balancesCheckContract, balancesTrack: balancesTrackContract, +balancesTrackTokens: balancesTrackTokensContract, balancesBatchTrack: balancesBatchTrackContract, // Events diff --git a/packages/openapi/v2.3/jsDocs/balancesJsDocs.ts b/packages/openapi/v2.3/jsDocs/balancesJsDocs.ts index 4cbe6bc9c..12ec2c81a 100644 --- a/packages/openapi/v2.3/jsDocs/balancesJsDocs.ts +++ b/packages/openapi/v2.3/jsDocs/balancesJsDocs.ts @@ -1,4 +1,8 @@ -import { ExtCheckParamsSchema, TrackParamsSchema } from "@autumn/shared"; +import { + ExtCheckParamsSchema, + TrackParamsSchema, + TrackTokensParamsSchema, +} from "@autumn/shared"; import { createJSDocDescription, example } from "../../utils/jsDocs/index.js"; export const balancesCheckJsDoc = createJSDocDescription({ @@ -58,3 +62,26 @@ export const balancesTrackJsDoc = createJSDocDescription({ returns: "The usage value recorded, with either a single updated balance or a map of updated balances. If Autumn is experiencing degraded service from a downstream provider, the API may return 202 after accepting the event for replay so it can be tracked as soon as the service is restored.", }); + +export const balancesTrackTokensJsDoc = createJSDocDescription({ + description: + "Records AI token usage for a customer and returns the updated AI credit balance.", + whenToUse: + "Use this after an LLM request when you have input and output token counts. Autumn converts token usage to a dollar amount using the configured model pricing and markup, then tracks that value against the customer's AI credit system.", + body: TrackTokensParamsSchema, + examples: [ + example({ + description: "Track one LLM response", + values: { + customerId: "cus_123", + featureId: "ai_credits", + modelId: "anthropic/claude-sonnet-4-20250514", + inputTokens: 1000, + outputTokens: 500, + }, + }), + ], + methodName: "trackTokens", + returns: + "The dollar value recorded and the updated AI credit system balance. If Autumn is experiencing degraded service from a downstream provider, the API may return 202 after accepting the token usage event for replay so it can be tracked as soon as the service is restored.", +}); diff --git a/packages/openapi/v2.3/openapi2.3.ts b/packages/openapi/v2.3/openapi2.3.ts index 68014d488..f591ca6cc 100644 --- a/packages/openapi/v2.3/openapi2.3.ts +++ b/packages/openapi/v2.3/openapi2.3.ts @@ -23,6 +23,7 @@ import { SetupPaymentResponseV1Schema, TrackParamsSchema, TrackResponseV3Schema, + TrackTokensParamsSchema, UpdateBalanceParamsV0Schema, UpdateSubscriptionV1ParamsSchema, } from "@autumn/shared"; @@ -64,6 +65,7 @@ async function generateOpenApiDocument(): Promise> { registerInternalSchemas(UpdateBalanceParamsV0Schema); registerInternalSchemas(CheckParamsSchema); registerInternalSchemas(TrackParamsSchema); + registerInternalSchemas(TrackTokensParamsSchema); registerInternalSchemas(BillingResponseSchema); registerInternalSchemas(AttachPreviewResponseSchema); registerInternalSchemas(PreviewUpdateSubscriptionResponseSchema); diff --git a/packages/sdk/.speakeasy/code-samples.overlay.yaml b/packages/sdk/.speakeasy/code-samples.overlay.yaml index e839f89ef..58322050b 100644 --- a/packages/sdk/.speakeasy/code-samples.overlay.yaml +++ b/packages/sdk/.speakeasy/code-samples.overlay.yaml @@ -152,6 +152,32 @@ actions: console.log(result); } + run(); + - target: $["paths"]["/v1/balances.track_tokens"]["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.trackTokens({ + customerId: "cus_123", + featureId: "ai_credits", + modelId: "anthropic/claude-sonnet-4-20250514", + inputTokens: 1000, + outputTokens: 500, + }); + + console.log(result); + } + run(); - target: $["paths"]["/v1/balances.update"]["post"] update: diff --git a/packages/sdk/.speakeasy/gen.lock b/packages/sdk/.speakeasy/gen.lock index 7275c6adf..b9a12a767 100644 --- a/packages/sdk/.speakeasy/gen.lock +++ b/packages/sdk/.speakeasy/gen.lock @@ -1,16 +1,16 @@ lockVersion: 2.0.0 id: 7b300647-cd76-49e9-bf77-7d1bf5446d66 management: - docChecksum: 8871f01e9bcff4b338df44c213b1e355 + docChecksum: bcc2c71aa4cf370bb9c6fb8dcb738c67 docVersion: 2.3.0 speakeasyVersion: 1.762.0 generationVersion: 2.882.0 releaseVersion: 0.10.17 configChecksum: 4722f16a8dee67ebd4038caf3c345296 persistentEdits: - generation_id: f374e041-3680-4d4c-bb37-4ab0614209de - pristine_commit_hash: cb76a996f7b1f4a11fb853cf30564f860c859ffd - pristine_tree_hash: 00b78979361e0ebbbac1ef29531df0aeb71cba05 + generation_id: 49c6bfa6-cb9b-43ca-a9e0-ce70758ef4ad + pristine_commit_hash: e1f3d5ef8e50c278d350ba4d69e996208024c9e4 + pristine_tree_hash: f9025b54bceb47ac5eafc3d9e92562c9c1e9b44a features: typescript: additionalDependencies: 0.1.0 @@ -125,8 +125,8 @@ trackedFiles: pristine_git_object: 417ce1ccb4e28e4bcdf691f4cb1903338fb3c868 docs/models/attach-add-item-price.md: id: 2860d88507a9 - last_write_checksum: sha1:aa8ed36782e0f098feb1fcec26dc3b3fae922164 - pristine_git_object: 6797b6e358d79db4bbca579d1fe58dafdcce30a6 + last_write_checksum: sha1:c732c66bde1ce62e11dceea76b0f2a033e4603fc + pristine_git_object: da139af04d640280c2ee94873ea0991ba15796b5 docs/models/attach-add-item-proration.md: id: 2ad49e8fce3a last_write_checksum: sha1:447132964c15e1302d64df0514ea17c8a034dd0e @@ -181,8 +181,8 @@ trackedFiles: pristine_git_object: a039746ccb81d1bb2109b96dc7da522da9897353 docs/models/attach-customize.md: id: a13da55a0eec - last_write_checksum: sha1:5ecb869dd633aa1e2e7476c9c951959eff30b2c5 - pristine_git_object: 9853f164aeea684bf8aed691314bb94ff144fc0b + last_write_checksum: sha1:0e384464151f3a465d465cea60069e4081723b15 + pristine_git_object: 9eaa940cf16397402a521cf7097a94315b23cb3f docs/models/attach-duration-type.md: id: 2b7e68923781 last_write_checksum: sha1:f447c936a788f2f8a2aa160edff651f58e4e2b54 @@ -199,6 +199,18 @@ trackedFiles: id: 3f5659c4527e last_write_checksum: sha1:5ecc4152cef4f786f4c1bb41b2b4ca42d51c5bdd pristine_git_object: 0bccf8fe67e3ec5e109afab60e5e17f4b54cb2c8 + docs/models/attach-interval-remove-item-enum1.md: + id: 1557479abbfb + last_write_checksum: sha1:5b6ec0691b8473e3b5e01b0ff6f7b9097d48658e + pristine_git_object: ac009b852984ab177cd6fdd31e71a2352b3c3e5e + docs/models/attach-interval-remove-item-enum2.md: + id: 7c01d1e7b13e + last_write_checksum: sha1:24a0272c4f0919b7df8167d6762811d67ccca178 + pristine_git_object: 63ae1cfbee9bc1246f70577c55744199a143f1ba + docs/models/attach-interval-union.md: + id: 6d8cec5035ab + last_write_checksum: sha1:f61d61a569da9565d7cfb5baf8431397d5f5f551 + pristine_git_object: db121c7bd5393e31fde8bf929af2f393dff65b06 docs/models/attach-invoice-mode.md: id: 06a085c65c2a last_write_checksum: sha1:dec3be9fb7e2358a6aa82e5a5f1c76ebcb6db9dd @@ -233,8 +245,8 @@ trackedFiles: pristine_git_object: fc077e29ba58add4b30b67b9da3a7f1b7ad538af docs/models/attach-item-price.md: id: a3b7c8da38f1 - last_write_checksum: sha1:52a56ae2d2439806f8b35b498c435a6975e85bc4 - pristine_git_object: 9010a7d48cbe0093cf9ed6eb8b7445c6e8b1e204 + last_write_checksum: sha1:1c860c5a1a80cc317dac4db423adbe2a7ef65db5 + pristine_git_object: 6333dd557db120a3cb94a02ea81d7275bbfe1fb8 docs/models/attach-item-proration.md: id: 86970c7513b0 last_write_checksum: sha1:3e92f47d2648cfe0d03ff5b06ff7966aa12075f8 @@ -273,8 +285,8 @@ trackedFiles: pristine_git_object: f2c7c5a04855b52808eda09a4bc58b747e4236c5 docs/models/attach-plan-item-filter.md: id: 0dab7a7cb98d - last_write_checksum: sha1:10dec6bc16ed91bb13fbfbcc6e69ad91ed21d31d - pristine_git_object: bd6b0a49e39315d331c3ceea232bc82eaee4745d + last_write_checksum: sha1:175512f8ba46e57bc97956105fe5fe10383e2c48 + pristine_git_object: 94466152c5f1ee930fc3b352aed7f46f142556f3 docs/models/attach-plan-schedule.md: id: e1a306c239b7 last_write_checksum: sha1:386200cce30a5fa82c62b0333cf74be2da7cdf59 @@ -295,10 +307,6 @@ trackedFiles: id: a6d60250e67d last_write_checksum: sha1:6de0a0e0fcdbfedb944b31badc127c85ed2d3759 pristine_git_object: 7ffba3b60b12742770d31171bac62ac94c13b54d - docs/models/attach-remove-item-interval.md: - id: cb004b54cdad - last_write_checksum: sha1:16ac903fa91f8d84031144d6ccef53b8a43133a3 - pristine_git_object: 734eac3ec769f1105712fd79a72366542d7cc39d docs/models/attach-required-action.md: id: 0273a38da163 last_write_checksum: sha1:e3e027765a84fc5793640b9a42d4ed631fc81628 @@ -321,8 +329,8 @@ trackedFiles: pristine_git_object: a95be33c4b8332212ea51227e0c9f764aceb1c48 docs/models/balance-feature.md: id: 55a57470f1c0 - last_write_checksum: sha1:72b80345840937f29b73fa476d1348da82677560 - pristine_git_object: 7c450f9bfdabb817ca1bee6b9020453d6d9bcd17 + last_write_checksum: sha1:7609563203c4f6577dbac3d230344fc4f436c7ef + pristine_git_object: c5b14f1f38b476a0a23408589e7a5738d8b8db16 docs/models/balance-interval-enum.md: id: dbc34d625754 last_write_checksum: sha1:4238eeb6473efb2780568f1732a83087cd5c50ce @@ -331,10 +339,18 @@ trackedFiles: id: ff5887fc3d49 last_write_checksum: sha1:84fd277aaebf90aadfe92f3946cde7e0127009e4 pristine_git_object: 4bdf29a6e689d74730ff98c82956348e1ce45256 + docs/models/balance-model-markups.md: + id: 3fad51579895 + last_write_checksum: sha1:73481a4246ed327dc683efd4a27feb0a4bb214cf + pristine_git_object: db59a19dbd2afdbb51bf4ff5391e27da89a80cb3 docs/models/balance-price.md: id: 0befed5ea384 - last_write_checksum: sha1:fda6ad63bfebad099b4f9714d1b0ff4b8f064ffc - pristine_git_object: 159d0ebcbd27002e352351f3983901dbddcba56e + last_write_checksum: sha1:d98eca78ecdf2d6d2b833ef1d3bb34ed4c62a02b + pristine_git_object: 365e312493d3c90629ed2e6d158677b44de27a57 + docs/models/balance-provider-markups.md: + id: 33f2c360f279 + last_write_checksum: sha1:db1b6fe25d9d5ac8ece8be11645e3eb4195edd2b + pristine_git_object: 0fd97c182227908704cd01ed791857a4f9586c25 docs/models/balance-reset.md: id: 86f7b6c57601 last_write_checksum: sha1:814b678055eae098196224dc4d913255b027085f @@ -347,10 +363,18 @@ trackedFiles: id: 49fad040b38f last_write_checksum: sha1:5c91cc7adf35dafa7e966d2d35937f92a46d3d25 pristine_git_object: bac50dabd4fb38447fa593172f8ea3113a86fb4e + docs/models/balance-tier.md: + id: f4fbeac81162 + last_write_checksum: sha1:b855f1f2e45397ca98adde43f14d2aa3ca675d99 + pristine_git_object: e6265daa10582a4b6815551a71560273e736e4c7 + docs/models/balance-to.md: + id: 9f9f60556eef + last_write_checksum: sha1:c6d5558ffe5fd3a02210edac78ed6144418b7731 + pristine_git_object: 8a6e80cfab2ae8d8bb2ba41981ab3aec43f29cae docs/models/balance-type.md: id: 193fed65f06f - last_write_checksum: sha1:da3f571fb4896b3d3caa20bf33c9c33c36f6faf7 - pristine_git_object: dfda3847ec0df4a8b917d5746722fe394b239258 + last_write_checksum: sha1:f9e0b05716f920755fefddd769f8828bcdf59d63 + pristine_git_object: 53fa4c3a343d2053715c41db4f6f5a9869e90a57 docs/models/balance.md: id: 9de59403580f last_write_checksum: sha1:8d5433ac6435693a81086e91ba42d7ae173e2a45 @@ -397,8 +421,8 @@ trackedFiles: pristine_git_object: cfb4af0c405cb76e34cbaff18308678e662b5dc6 docs/models/billing-update-add-item-price.md: id: 753aa01609b7 - last_write_checksum: sha1:52d67f2c1e508d87a824f1028b5dfa6dc06a6f9a - pristine_git_object: 2c51bd2c383c6c2336c1f2e935fd062d2f03f84f + last_write_checksum: sha1:3fa1f931fd48bc6660fd6c6848b12a6c2e326a8c + pristine_git_object: 1bf43c52cb690aeb020d8c7d3224e17874bfe541 docs/models/billing-update-add-item-proration.md: id: d688f3ae717c last_write_checksum: sha1:85e02f6f6678da174097a0f7053a0b537434f860 @@ -445,8 +469,8 @@ trackedFiles: pristine_git_object: 7c5c0d332961e51e90e875930bc57fc5769f66dc docs/models/billing-update-customize.md: id: 533e2ca5e4bd - last_write_checksum: sha1:40c4d4c0cce5253fabbfe923d56af4fe97547037 - pristine_git_object: 496080990229cdac916c5eb218996f62b68545b6 + last_write_checksum: sha1:84dab799449e81d8b1192cd41c324c7d88c96817 + pristine_git_object: 39bfbc8470673e4fa1e3b39bcfa8f7f9f9945631 docs/models/billing-update-duration-type.md: id: f711ec227260 last_write_checksum: sha1:98a5be59f2473d00d1b58725a918223ab19fa8dc @@ -463,6 +487,18 @@ trackedFiles: id: 77f718b86108 last_write_checksum: sha1:944990596aaf33c8cf2bddcfe7678d4200ab7aa8 pristine_git_object: a5ca2395c55d099fdc8ca0eb2ca7d93121cc36a3 + docs/models/billing-update-interval-remove-item-enum1.md: + id: d291799c424a + last_write_checksum: sha1:9a114a6fd311527ec780ea015411adbe9bd0575e + pristine_git_object: 3c586cef6839c66539f2d82f85c2b42a5f4d97e5 + docs/models/billing-update-interval-remove-item-enum2.md: + id: f4be328b2a61 + last_write_checksum: sha1:36246364caadcc5a57937f7692d500906c6015bb + pristine_git_object: e0481f0ba44565f94573762fe5cf008f0adedf25 + docs/models/billing-update-interval-union.md: + id: b3ae71f3db06 + last_write_checksum: sha1:f0d91496e49a7a2862d001f9c9f399cc7f46a0cb + pristine_git_object: 2916cfbd3384ea7b1f2a441a3189a853c3463313 docs/models/billing-update-invoice-mode.md: id: 33eaf6fc88e5 last_write_checksum: sha1:c7d8a3d2d3dddf9afc4c529ae246f892558ec84c @@ -497,8 +533,8 @@ trackedFiles: pristine_git_object: 116ca195cbe43ef0b876278fb8c5b7c20f1e46fb docs/models/billing-update-item-price.md: id: 394a0a1cba93 - last_write_checksum: sha1:f19f9cb6f90f36c79f7b181b4f5e4d3363b54f36 - pristine_git_object: 628478cab02b07e0314ca9a99b2166f298987122 + last_write_checksum: sha1:2b91f4bd4caaffa3b27a62af9264f10f422e0253 + pristine_git_object: b2f1b97e3ef022e0e7a69b28f2a0fa84c41e9958 docs/models/billing-update-item-proration.md: id: 84c0014d6ac2 last_write_checksum: sha1:efe65a4e9be6bef3c8f8baf6fc8608d122af0224 @@ -533,8 +569,8 @@ trackedFiles: pristine_git_object: 0ab8144b2c8c679edca1190f9b6893ab77dc9a71 docs/models/billing-update-plan-item-filter.md: id: b1aaaa6473ba - last_write_checksum: sha1:c4e3957e89b9604cbb7d4a7a063a78ca88285a7f - pristine_git_object: df2972ff31f181b3cc525b3a89cdda686d728dbb + last_write_checksum: sha1:077ff9cb43bf9864013a3473bfd4acb09a901317 + pristine_git_object: 2cdc73bc69d119e72b95c9c4e1bcd3a5726133ce docs/models/billing-update-price-interval.md: id: 37ffd299355f last_write_checksum: sha1:13691ac769a15a7a54173e67c1745bfc1c17b0f0 @@ -555,10 +591,6 @@ trackedFiles: id: cf4ff98de0a6 last_write_checksum: sha1:7d8e186f34ea574ace18895d20d9e664f04be9dd pristine_git_object: 4e9a12b8f2d99eea5d96f664d6d2e6cdfd99ce3a - docs/models/billing-update-remove-item-interval.md: - id: 180b4ca04814 - last_write_checksum: sha1:445d4f37879c5145450017c224f142f0d7d0a685 - pristine_git_object: 7b9abd959d6e5cc67cf5317a436de3393fa8ade3 docs/models/billing-update-required-action.md: id: bf7357d5d4cb last_write_checksum: sha1:a32fbd281486508857937d2eab1e6c3ecc6e06d0 @@ -601,12 +633,12 @@ trackedFiles: pristine_git_object: 395bc6a69007183a1e85dfe5c7e36a920280a33c docs/models/check-feature1.md: id: 4911e5ca3bbd - last_write_checksum: sha1:6cf187837869d8b6e7ed6317f54889850408f36a - pristine_git_object: ea7f5ff13062d1e64b8804efe7f305f4d25935ea + last_write_checksum: sha1:d630684d595a5eeb75a6793857ff2c7cbd2fe338 + pristine_git_object: be6ecf89f3b8964e39a2f508e2fb48467c4cffbe docs/models/check-feature2.md: id: 3e789aa99fd1 - last_write_checksum: sha1:5d7218e3d596e6ae9283c057cf0bb7d8ff46c6df - pristine_git_object: 6fd456302a01f5b85a9645ba8dcc8d5652f10cd1 + last_write_checksum: sha1:fdc32273fc333f20da0a483ca9f72bd32833afa8 + pristine_git_object: fb1980008a0e15c2f700f2f94e264a3dc3315a36 docs/models/check-free-trial1.md: id: 13ac0bc6851a last_write_checksum: sha1:cc40e1842925cd8c8ff0986680b49aeb4f7c913c @@ -639,6 +671,14 @@ trackedFiles: id: dcd32828bfb4 last_write_checksum: sha1:e1abe8cbfa6ae715aee62cd16d53fb9fe9bc42d5 pristine_git_object: b21ba54a0652e2c016fcb4434880c429fa4c41b3 + docs/models/check-model-markups1.md: + id: 3de0f5bbf272 + last_write_checksum: sha1:c4532df14c9aec74e1189f43ee9c9651e3beecf2 + pristine_git_object: b8010ce5739aa1707cb434c3c751b6af3ff4cba8 + docs/models/check-model-markups2.md: + id: ebead4abac53 + last_write_checksum: sha1:1dbd86e51331918d7ea43eafdc150d495cfd6c98 + pristine_git_object: 9595da9d930d071db1a757d4a8d6e340eab14247 docs/models/check-on-decrease1.md: id: bbd6ca066659 last_write_checksum: sha1:5ac919c7c94dce26869b1b96263edd6fe0ef6ffe @@ -675,6 +715,14 @@ trackedFiles: id: 7d4ef4fdab22 last_write_checksum: sha1:8aae0bb7098cda51316e5e6eb83497f73a740fcc pristine_git_object: a5cf026e769ee5a7302238cbbcab7752bcf0de9e + docs/models/check-provider-markups1.md: + id: 6079ac4fc533 + last_write_checksum: sha1:6b8b2e6ba5c16d79785ef9ee28d9ea4c5c4215a2 + pristine_git_object: e2b5b985ed0add9afbd63d3f7bb490c1eab5871e + docs/models/check-provider-markups2.md: + id: 843857ba2952 + last_write_checksum: sha1:30d2fb9c8d647b51b425d3f6eb0f3f052fc87de0 + pristine_git_object: d2dde20ad2f907c47d59b406771c1518f467ad3b docs/models/check-response-body1.md: id: fe7fb45cea45 last_write_checksum: sha1:8844e03a5e1af097130d9578157d06c96ecec130 @@ -761,8 +809,8 @@ trackedFiles: pristine_git_object: 8e59ea8fc2938ea30a6e9b582c44a4b5cab2a46d docs/models/create-entity-feature.md: id: 53cedd277218 - last_write_checksum: sha1:190a8d28cdead2c5bba81d15a0afb9a748825e9f - pristine_git_object: c436a5fcaf9c87ce63639636a8b5420580def3d5 + last_write_checksum: sha1:410280f288bd720071cf309fddf7ae915d833a54 + pristine_git_object: dbe2d7f0ae33d84857e8436257adf880f2dc7188 docs/models/create-entity-flags.md: id: 8919b9096f21 last_write_checksum: sha1:d1ee50ef741bed5111670d7d6bd8067e47cfcf60 @@ -775,6 +823,10 @@ trackedFiles: id: b02e52191f2a last_write_checksum: sha1:5e0d773805ba54fc926b24cbb38bafb2bd1dcf6c pristine_git_object: a6864dd58eaeae8f51d0eae45f1014125a17a522 + docs/models/create-entity-model-markups.md: + id: 8d7b6aa60602 + last_write_checksum: sha1:a2597c788ac06b621cfc4d5684bc4971f0335765 + pristine_git_object: 9b1d80c7cdb38a5a2a5bdc688557384ef47e4285 docs/models/create-entity-overage-allowed-request.md: id: ef64702187fd last_write_checksum: sha1:5e65e4edbe3524ab3ec6e8829852ff1ac39a106e @@ -791,6 +843,10 @@ trackedFiles: id: 6607564588d5 last_write_checksum: sha1:1cc8a06d6103c189ccb0c90d9b92935dc4916b9f pristine_git_object: c41b9f77e9ccf935279b0d71b0719bc218065b09 + docs/models/create-entity-provider-markups.md: + id: c04b1ebc1e7b + last_write_checksum: sha1:cb105f9e8f843c6f9bd00f8c44f63dfaa41873d0 + pristine_git_object: 7559364a9cb3dfcdc914ed6e8a283397118ca539 docs/models/create-entity-purchase-scope.md: id: 9691e19134cf last_write_checksum: sha1:70378ac5e9d81ee102dacf7df1bf368f600a651b @@ -833,8 +889,8 @@ trackedFiles: pristine_git_object: 82d7a19c59bf9d80cd6ac5df6542e867073df563 docs/models/create-entity-type.md: id: b7fd22bd7861 - last_write_checksum: sha1:a0f47b4b77657a52ac54e3ee07b553675bb9dd5c - pristine_git_object: 99caea31ac40ddd636425c0c559b31302d494cb3 + last_write_checksum: sha1:6a7a7f69e98214d4d21e1da281cb237bda82f49c + pristine_git_object: 4ed13a9289e54d4dc99342321f4d5ec7fb704060 docs/models/create-entity-usage-alert-request-body.md: id: a3441743c411 last_write_checksum: sha1:31878c704c260782dae98f3cdfaa8f0599e104da @@ -843,18 +899,18 @@ trackedFiles: id: b4c50866191d last_write_checksum: sha1:fa3be8331198f499abf93e3ba078f9480e68c46f pristine_git_object: fa51ce293a94daae8f3c397c45e1043badb432f0 - docs/models/create-feature-credit-schema-request.md: - id: 12282f670bf0 - last_write_checksum: sha1:68cb7e44ce4c8e0a8daacf13e2cb0e5afecdc172 - pristine_git_object: 37823efaea8bc413fb99b2e111c6b027ca6f56b7 + docs/models/create-feature-credit-schema-request-body.md: + id: 767d056f948b + last_write_checksum: sha1:51839b09cbe1501c51baf627121da6cdefed0775 + pristine_git_object: 84a8e5b5798122633ca80b9d85854ea369d6ebc2 docs/models/create-feature-credit-schema-response.md: id: f552cf64930b last_write_checksum: sha1:a55d22d770919d6a7d1bdc41be21ed73846aabb5 pristine_git_object: d59cc89585fa1564b7a04cbd342ab2f61097bb6f - docs/models/create-feature-display-request.md: - id: 83f16d1ece71 - last_write_checksum: sha1:e69f9119fc599da1c2b4d8d6e3c8b7aa18337bc0 - pristine_git_object: da48687ecadb222c9844990821db0c10f277b347 + docs/models/create-feature-display-request-body.md: + id: 60d46d2ce851 + last_write_checksum: sha1:f1303fa54a4059c9cfe713beb616278e7a4ca731 + pristine_git_object: 398cfeb7d7450946e842594f90e6618d135bdc26 docs/models/create-feature-display-response.md: id: 38eb71a6a4af last_write_checksum: sha1:7c8648f0d63e2700e55f4921ab213b6551cb61db @@ -863,30 +919,46 @@ trackedFiles: id: bfd185805878 last_write_checksum: sha1:51796923da5a373db83bc2aaf40d42b6d6814219 pristine_git_object: fd31133e33039cbddd2e41efe887700045a1bfad + docs/models/create-feature-model-markups-request.md: + id: 39f2298fdc40 + last_write_checksum: sha1:1e1f85ccffc7616147437f65f83244ab56d25076 + pristine_git_object: df6d67eaebdf78b8cd502885e8274da5cdc7013e + docs/models/create-feature-model-markups-response.md: + id: 30df31982e8a + last_write_checksum: sha1:abbb90e9dfe5d4920aea11cc1d00e32b234ffc34 + pristine_git_object: 2442b714cb42a3c09daa913a0e9e43f3ebdb47cf docs/models/create-feature-params.md: id: c3eb39d46398 - last_write_checksum: sha1:df7b37d8a5c4772c51c410e61d8194680bfd58a5 - pristine_git_object: 1cb1fe22af209be0a8be7941af7217f7d607c0d0 + last_write_checksum: sha1:952d95f9e7714e9cbde71ce950148d6c98f933cd + pristine_git_object: 7b024f436c594eae761d7ee298330724fca4362b + docs/models/create-feature-provider-markups-request.md: + id: 5d799081128e + last_write_checksum: sha1:0fdcddbe8e0883fe54b939f5e0587d17c84dcfdc + pristine_git_object: 93d45380d70b66d94e269a5515f12c60a765ad5f + docs/models/create-feature-provider-markups-response.md: + id: 1ff10689b4fd + last_write_checksum: sha1:fbc915b2ff7b0ef7c8637100aa3760b5006ce82a + pristine_git_object: fe7c9a3f0a27014301f277d8bbf75f31e3ba51e4 docs/models/create-feature-response.md: id: bad8a652a8ee - last_write_checksum: sha1:d24c63914b61cc42068668632675f67d19a2136e - pristine_git_object: b29cd129fdf1ec14675536c6d740ad06d104098c - docs/models/create-feature-type-request.md: - id: bc031e6b753d - last_write_checksum: sha1:538dec343701be917e112d325a893e1fe9b1cdd4 - pristine_git_object: 59486a5f87ebfb477b8ac9c379cafe031fc1084b + last_write_checksum: sha1:dcf9c24183f7ead811f434c0aaa8df99b0bedf4b + pristine_git_object: fb7c2017c82fe5a48f2d0644d3218d31c133a312 + docs/models/create-feature-type-request-body.md: + id: 01af0d3973b7 + last_write_checksum: sha1:d83f0acc690969c4e0ce9d820741e40a577e85ec + pristine_git_object: ed1978c4f856cc8973a752584c4ad39eca8161d1 docs/models/create-feature-type-response.md: id: ec96747ac5a8 - last_write_checksum: sha1:a02cc9462eca35ec9d6d4149cb145a15be09679f - pristine_git_object: d45c54c26108de2eae9d7bcd881db79fac927fc5 + last_write_checksum: sha1:858c44431a30ef17167fff934af73fc83a46bc3d + pristine_git_object: 72a059458325e4f2783881813692e3fa7e946dc8 docs/models/create-plan-attach-action.md: id: 3a9b41faa0c8 last_write_checksum: sha1:570d06010522edb4dabb3c27b26fa66aa2fcf909 pristine_git_object: 11bf4a9d74e75a895e27489a55444fbe76d6488e - docs/models/create-plan-billing-method-request.md: - id: 5910c31fbd40 - last_write_checksum: sha1:f760605fbcf453ca76282db0ceb6c56dc471198c - pristine_git_object: 881afe6799b816389f7726b398c8703a5b43a1eb + docs/models/create-plan-billing-method-request-body.md: + id: 98110fffd078 + last_write_checksum: sha1:d87b417b7e527e632cdbee142f0d37beeb006426 + pristine_git_object: 7b92cf475fe77f21857ed9b43d619a4e79d49c9e docs/models/create-plan-billing-method-response.md: id: 4b62f4430e70 last_write_checksum: sha1:6f14cf6ec48162bc6154580375c55f699b9925a1 @@ -919,10 +991,10 @@ trackedFiles: id: 6cc4a7432037 last_write_checksum: sha1:e4c71f4bf1bd9c341e40da5e7c1a5da14fbf7fad pristine_git_object: ebe0cbac88d8f8430f6d870409e8e4e17f6ec7d2 - docs/models/create-plan-expiry-duration-type-request.md: - id: 64f50835e6bb - last_write_checksum: sha1:5567b9305c2c0a0eb6a0507b62b5f1dc8569dfe9 - pristine_git_object: e24e9550a7967f519354812a14490b5a2e6c3840 + docs/models/create-plan-expiry-duration-type-request-body.md: + id: 90561194b195 + last_write_checksum: sha1:7677cdac08f7420c027668235dc626c4872a2ca2 + pristine_git_object: 26087ef9a6d9d8b0ca60c05f1ac84b8e4cb4e5e3 docs/models/create-plan-expiry-duration-type-response.md: id: c2e3d11324b4 last_write_checksum: sha1:ac4d671900f88510bc21d82b4c4bc680d402836a @@ -947,18 +1019,18 @@ trackedFiles: id: 13cbfeba39b2 last_write_checksum: sha1:c204040826dd220174269e41645e08586d2968d2 pristine_git_object: 85d51c73bae5a558f84bba9ae51c7f18ed9cf1f7 - docs/models/create-plan-item-price-interval-request.md: - id: 358402a891d2 - last_write_checksum: sha1:ad8c021d5db96fba5ce04e0aae03ce94880a9c1f - pristine_git_object: e7e6a8e39482cc4fea622616320d6e60c221971b - docs/models/create-plan-item-price-request.md: - id: 27895133faf5 - last_write_checksum: sha1:45dbeb494cc6469648cbe2fe9529d8ef70297e01 - pristine_git_object: 59ae5e7a3e869a5f37912ba19880bddc7f332541 + docs/models/create-plan-item-price-interval-request-body.md: + id: 8906d123e5e6 + last_write_checksum: sha1:0980ac66cb0415e46525c6c27f53c75c585b912b + pristine_git_object: 8f45ebfdd330fa83de898b63d5a187c0cb251b1b + docs/models/create-plan-item-price-request-body.md: + id: 609b371966ce + last_write_checksum: sha1:1472b0f54948876744c5c2352d78be8eaa9d61be + pristine_git_object: f2e3315822961ea455f890f3ad25a568d41ad5c5 docs/models/create-plan-item-price-response.md: id: 9ef4d0695a07 - last_write_checksum: sha1:344ae9d06820aaf83363a65b2cb224fdb312fedd - pristine_git_object: 3eaf3ac09c584467f909a5d5c614709aff4bd5b3 + last_write_checksum: sha1:cf7c023bb92835d4e2f1bde9809582c8a2cd5ae6 + pristine_git_object: 107e7733aa22e582e6800310c40f43fbfda5cfef docs/models/create-plan-item.md: id: ecb598dd1edd last_write_checksum: sha1:e4c513d386c49e1a5188b7547bbbc98fd0f10688 @@ -981,20 +1053,20 @@ trackedFiles: pristine_git_object: 4636c8e7bc514c5398be5f2af59d18fcf652cfd3 docs/models/create-plan-params.md: id: 9570d70789a1 - last_write_checksum: sha1:147febd002de704135d55e06f5a403a42ed8b9d1 - pristine_git_object: c44b7e51f8cf1ad60781e167e0533e9d1371f2c6 + last_write_checksum: sha1:b676a40de0acec27dfa9cfe7f78d19fddd8ba334 + pristine_git_object: 67f55c1f5115b438472dc1cf49f3cf9765a159c1 docs/models/create-plan-plan-item.md: id: cc6b2127965e - last_write_checksum: sha1:32b1546a311beca13ff1ec2ea13f55fe67b839fb - pristine_git_object: 2af6fbbbb0068518435c854387ede58020b6a58c + last_write_checksum: sha1:9bc8ee45b4b278a93fb2a3fc286ed7c63b61ed47 + pristine_git_object: 1eb6b75bfcb96a78ac128ea3b5589f15b3653ba3 docs/models/create-plan-price-display.md: id: a9f0ab44c193 last_write_checksum: sha1:520bfd5d1b3dff4b94c38ddb6c22b4cc2061c604 pristine_git_object: d5cd57a4a8155d518e7bb2762a925f618c6fb9d2 - docs/models/create-plan-price-interval-request.md: - id: dbcf5b1fbaac - last_write_checksum: sha1:cb99d6223798db87f9f07fc4257a54c3a860efed - pristine_git_object: 041e09cef3859854ecdf8997334bfdf94e732cf0 + docs/models/create-plan-price-interval-request-body.md: + id: 29518e88e583 + last_write_checksum: sha1:8d006dc9b3f853130d7772118e7bbd351cd18fbd + pristine_git_object: 1d1ae40bbad01d81d24a28cf2ff9a5ca5116c560 docs/models/create-plan-price-interval-response.md: id: 171e588628d3 last_write_checksum: sha1:8684f1bae40c7b9b4dd50c234a014c69ffaf9c64 @@ -1003,10 +1075,10 @@ trackedFiles: id: f82a720e74cc last_write_checksum: sha1:ab3d807d35c4d6a108593aec37e087c3efc54867 pristine_git_object: b689fa821d998e5388d12f3fb7f8c1f21021f08d - docs/models/create-plan-price-request.md: - id: ada0d70e716a - last_write_checksum: sha1:1e5c54f88963da99a3cafa25d41bfd0a5b3fd8e6 - pristine_git_object: 3ce15921be092fec1a23f861ab9e4ba71e30da3b + docs/models/create-plan-price-request-body.md: + id: 3537c7050a36 + last_write_checksum: sha1:312865d6bd2db1eb5545f6d0a4806c9ea7b1412f + pristine_git_object: e8ba7d18074fd9a6f1e017f51157c2f9a6e11293 docs/models/create-plan-price-response.md: id: 9d8f1ded12b9 last_write_checksum: sha1:256c1600a67e07d069085796baa867d0e8933400 @@ -1015,18 +1087,18 @@ trackedFiles: id: b76e93ebb4c1 last_write_checksum: sha1:aaf8282a06a3cb2e7e12e945596e6fcb9150fb9f pristine_git_object: 13bec79193ccec28d49424db88530706be331870 - docs/models/create-plan-reset-interval-request.md: - id: 313b5e0e6045 - last_write_checksum: sha1:b9108128c222188659b07d47738efe774dc3e1ff - pristine_git_object: 3ab22b050c282b26f8e10b52d9cb96285e13f734 + docs/models/create-plan-reset-interval-request-body.md: + id: b6eb493564c4 + last_write_checksum: sha1:ebdab17aa7993400f006b4e873a0eaa70c83fa55 + pristine_git_object: d32cf7e75b32342a63b04acac7c30d876a757b08 docs/models/create-plan-reset-interval-response.md: id: f48e94cb8d80 last_write_checksum: sha1:4ac662e7e53d41d7d4082e38e6869391ca746e15 pristine_git_object: 18e647441adf9d2f6bbde2a55c9b56748cd7ef6a - docs/models/create-plan-reset-request.md: - id: d88e31eb9636 - last_write_checksum: sha1:faa2ed1cdae4ae1ff4534b709c718e1a854c7ee8 - pristine_git_object: 4e26ac4eeab4065bf58873f76a3af6b170da6c6b + docs/models/create-plan-reset-request-body.md: + id: 3f8db98fb5e2 + last_write_checksum: sha1:415875df63efe17957f8f14849284c2ca923a6da + pristine_git_object: d482f2639173b22dcda48ff3864745a661e59857 docs/models/create-plan-reset-response.md: id: 356903b9ca36 last_write_checksum: sha1:144b0ded468c23908dc2024442f5be313142e3a2 @@ -1035,10 +1107,10 @@ trackedFiles: id: f48e8b1136af last_write_checksum: sha1:caacca009b6ebd1a12cff7200d485eed907db391 pristine_git_object: f618e72a80ecdefee38ff6c7fc4efe12493bdaeb - docs/models/create-plan-rollover-request.md: - id: 10237b75cd73 - last_write_checksum: sha1:4c1b0ddde1b8d4232668b42e694cff3ca1c19f43 - pristine_git_object: 29bab8aa4da16c4afabce718237430b3c633cb7d + docs/models/create-plan-rollover-request-body.md: + id: 5110e38ce542 + last_write_checksum: sha1:2f2f823d00e95eef4a957bd2ac543702181eac6f + pristine_git_object: f1a80e76b23daf2031b7ffd9a41561d6c8794a23 docs/models/create-plan-rollover-response.md: id: b5e9ec426df2 last_write_checksum: sha1:865b16c5bf3aac29b32a30fdc5c4c7e68942eb53 @@ -1047,26 +1119,34 @@ trackedFiles: id: cb2e4b4b718d last_write_checksum: sha1:5d4dcc420558a49ccdaf2931738ebbe09dfbb2f5 pristine_git_object: 01165a04e3c5887958957a73150ea166229d86f7 - docs/models/create-plan-tier-behavior-request.md: - id: a8b6b86a026a - last_write_checksum: sha1:9ef8eb045da5b7fd0865603b7482d16f34dbe425 - pristine_git_object: b3a2fb612213fe3ab0669c4c7e197ab83b3b2f36 + docs/models/create-plan-tier-behavior-request-body.md: + id: 66fa54e19207 + last_write_checksum: sha1:7cc06191c5190cde3bad0870e9cc65b8534ad03d + pristine_git_object: 5155e66f4b193142645d65e86d192d7c1ab60fa9 docs/models/create-plan-tier-behavior-response.md: id: efafb363cb61 last_write_checksum: sha1:98d171de5564d6e60e0844caf3ad8663285b70dd pristine_git_object: 433e1c442a5f1c99bd755e45dc4b02e07afab7ac - docs/models/create-plan-tier.md: - id: 9f8755fab2b3 - last_write_checksum: sha1:9cef313a71dfe0ebcbfdfa3158e5a09cf380db2e - pristine_git_object: 24ddd8c7a07508070bfca585df1ffb6f707eb87f - docs/models/create-plan-to.md: - id: b301cdd211f5 - last_write_checksum: sha1:1b1e11f186b686df96134bdaf2d5b0307ffa1040 - pristine_git_object: 86be2ef4df506af2e5f0886c0cb149453e6bfe8f + docs/models/create-plan-tier-request-body.md: + id: 3640a09bda18 + last_write_checksum: sha1:e548b1eece42cd3b88ead47f5d8220bcf5f7bac2 + pristine_git_object: 7d643d1a849f2cbdaa46ecde44239f1e30b58d13 + docs/models/create-plan-tier-response.md: + id: abb65350cb3f + last_write_checksum: sha1:fadd3a58d3781b4c25eb212816c72561943e5053 + pristine_git_object: bc1e666a1a16fd285eb3e532ad40808198d271c8 + docs/models/create-plan-to-request-body.md: + id: 2d5105610fa0 + last_write_checksum: sha1:58db7d5c03f81a4aead1efdfbd08ea197f0fafa5 + pristine_git_object: c56cbc2c736c98b2421dcef907e71e3bf209508d + docs/models/create-plan-to-response.md: + id: e166a2e7b741 + last_write_checksum: sha1:61960c4c90dc8b462484cba4467d2fc063539299 + pristine_git_object: 8f6cb30103a0636e0b338980022f588c1b4b23ed docs/models/create-plan-type.md: id: b41ecee1f888 - last_write_checksum: sha1:f6b5067df1df12946d1d846d73d126ac5ebf2d73 - pristine_git_object: 0ed951484d4a3ca7527f3dd73320b24f3e4e3127 + last_write_checksum: sha1:6e7edd45cd616f468862608211e40582bfc3d8b5 + pristine_git_object: e7efc1b680f88e9909a1da85c9f7bfee6d7e5b27 docs/models/create-referral-code-globals.md: id: 6e32bb0907b2 last_write_checksum: sha1:35e5d951bf5ff283f2c8325e02b597ec695cd0c4 @@ -1079,6 +1159,58 @@ trackedFiles: id: 8d8c5e4e1502 last_write_checksum: sha1:94a43a0d34bb024010eb069d5d2e71b4d5f87d39 pristine_git_object: 4971dcd50e07c557f14ab1fe6c87ff430903d288 + docs/models/create-schedule-add-item-billing-method2.md: + id: 9bc13e5d5f07 + last_write_checksum: sha1:69e6ed7d7cbec4e4456a57ffeff58a831d0a03a4 + pristine_git_object: 917477aed4d3cd67a92f3b566b868c5add12bb2d + docs/models/create-schedule-add-item-expiry-duration-type2.md: + id: 68997e378e3e + last_write_checksum: sha1:3ed3a98454746ee0384253e516fc34fcdbc5c3f5 + pristine_git_object: 7d1903d9d6d5ab0b00b9b85b2a68a8799a379717 + docs/models/create-schedule-add-item-on-decrease2.md: + id: 0f461ed8fc9c + last_write_checksum: sha1:81a6a033017b9856dc21541d02bac3baad301d01 + pristine_git_object: 741d70b3cce283d4f9ee00d597eb484196ad60de + docs/models/create-schedule-add-item-on-increase2.md: + id: 6d398923c73f + last_write_checksum: sha1:c207e4c20cdfae578bd3fda062ce777754e92766 + pristine_git_object: f855ee192e4a9ab06645679330dc387f5b017aa4 + docs/models/create-schedule-add-item-plan-item2.md: + id: d7193e7c4e71 + last_write_checksum: sha1:4b26d911c0730b7628599bc40e6aaf648c25cd1b + pristine_git_object: 43d9cfebb12f486ec669fdc73327d0aed48e090e + docs/models/create-schedule-add-item-price-interval2.md: + id: 6ee6554a653e + last_write_checksum: sha1:fd851156232f6f8078e38c93238c257744801824 + pristine_git_object: 480b24f225f94a6659959ec074a7e88292279d4d + docs/models/create-schedule-add-item-price2.md: + id: 884de390ca04 + last_write_checksum: sha1:eefb4423048595d50aff1c822d1fb7f1bad749db + pristine_git_object: 58f67161a2dc5257c73340bfd083a4569d8df9f3 + docs/models/create-schedule-add-item-proration2.md: + id: d06db8c347b2 + last_write_checksum: sha1:6aff569dcbe911600177d33296bcce65823218ed + pristine_git_object: dae4d196a73221a94fce57d7273c9837ac7245fb + docs/models/create-schedule-add-item-reset-interval2.md: + id: 24779509aa09 + last_write_checksum: sha1:c97be4d037585e34a5f8865ea19dcde3c26de218 + pristine_git_object: 075df31f79acfe22d939c18a9c711f245e0149bd + docs/models/create-schedule-add-item-reset2.md: + id: b6a1700e48c7 + last_write_checksum: sha1:61150c2b9922777639a71d02ac8ee7948c7a9e43 + pristine_git_object: 55c6a95bf88536b2aa47d997bd537ffd0a128757 + docs/models/create-schedule-add-item-rollover2.md: + id: 8ef7c3615ded + last_write_checksum: sha1:348c6249ae5f572f539dcf284bd779077096c732 + pristine_git_object: a6a983b79cdac250003179a94873651f30b65553 + docs/models/create-schedule-add-item-tier-behavior2.md: + id: dbf60b18e610 + last_write_checksum: sha1:deeed52c9c90cdd6a46ae5a9a34f721bf303c2dd + pristine_git_object: a30646d444846a19b838146467ec144d3f6c02a3 + docs/models/create-schedule-add-item-tier2.md: + id: b5ea2afc2370 + last_write_checksum: sha1:aed64ab2dc49401f4ed651c2084449efe986ef7d + pristine_git_object: fc6197eaf4db87a08619afb9afe053a978ce4afe docs/models/create-schedule-attach-discount.md: id: dab607210d95 last_write_checksum: sha1:d728c6486f5fa8bc666dbe65778f217ad1d054e1 @@ -1087,22 +1219,14 @@ trackedFiles: id: 56793b9353fd last_write_checksum: sha1:b2a0bce4529470dbec2cffe056442d3a54fb2d20 pristine_git_object: e50e41787624b816d1e788e9811c83bc950054c4 - docs/models/create-schedule-billing-method2.md: - id: 6db401b3f4b4 - last_write_checksum: sha1:f3528e6989753a523ed5e91a7ed4f1f2613af78d - pristine_git_object: 04a270b0813e48039f67c6af8fb117aa0e87f767 docs/models/create-schedule-code.md: id: 4c18cfab5c24 last_write_checksum: sha1:bb168ff66d793854b4af4f553eb3c87e68e7c444 pristine_git_object: 7f13ed77e162527db1c4c9ef65f253da50cc8826 docs/models/create-schedule-customize2.md: id: bffe7e3c5eab - last_write_checksum: sha1:b0cf3176a808bd2bfb31934a8410008582e55339 - pristine_git_object: 4a572788630cbe6ec4ff31f406a497eeccfbf60c - docs/models/create-schedule-expiry-duration-type2.md: - id: c8b79c7e4c5c - last_write_checksum: sha1:968651076e3cd1584f61805567b9db5d3bbabdbd - pristine_git_object: 84e0cb3172bd7962441a902738ae6e2e5fc2a5e0 + last_write_checksum: sha1:537f567812fcdb9ce4b9edfe6fc8cdc44afa0105 + pristine_git_object: 35829739d54a477b0614e4653552dea112b8c1a0 docs/models/create-schedule-feature-quantity2.md: id: 68b7c5bba52a last_write_checksum: sha1:7cafe0db5cc452c42ec1f9067a42106a72ff0357 @@ -1111,6 +1235,18 @@ trackedFiles: id: 5b553920a9e4 last_write_checksum: sha1:9cc4661bc97adcd7365848b6c35d09057e74646f pristine_git_object: 995336526a473eee9e0d8c01f3903b580baad3cf + docs/models/create-schedule-interval-remove-item-enum3.md: + id: 60a71da59929 + last_write_checksum: sha1:a12680da82219dddafb631938c6a18e890ba6593 + pristine_git_object: de712f990a32d4f166bc52ab1709ed8980b56033 + docs/models/create-schedule-interval-remove-item-enum4.md: + id: c9e65f2e1318 + last_write_checksum: sha1:bb559a81e1641f610813e167f37bbc18206f2d41 + pristine_git_object: 9858e85c0424c1544019f33f468d88c15dee4f97 + docs/models/create-schedule-interval-union2.md: + id: 5834580bdcf5 + last_write_checksum: sha1:7e9734347c5f57f6238461f64188906e4e2f1ce9 + pristine_git_object: f8f68db9783ea3fac0835bcb1981c46f1cd26a1e docs/models/create-schedule-invoice-mode.md: id: 31a296d3edf2 last_write_checksum: sha1:9abde7a6fdad79b4ac7e0f15a03e27cdbc9700b8 @@ -1119,78 +1255,94 @@ trackedFiles: id: 8c5ca97f625b last_write_checksum: sha1:a8705e08f8a20efb4463ef8640ebd7742da9ebb0 pristine_git_object: 335ad261a9cd0ac294bf2fc8d26af8826e041ece + docs/models/create-schedule-item-billing-method2.md: + id: ba7102b610a5 + last_write_checksum: sha1:588979ac63a09194ca45c360b70082adaaba9b7a + pristine_git_object: d56948313a4724df45d2d2cc4b59671a2f851665 + docs/models/create-schedule-item-expiry-duration-type2.md: + id: a428685dc86e + last_write_checksum: sha1:49158badef9feb47b4266e4e4f4ca8a3c7e1bcb0 + pristine_git_object: 8eab0267e5daa3cf0755a59e2a9807c7f4800d06 + docs/models/create-schedule-item-on-decrease2.md: + id: 2001e85087ad + last_write_checksum: sha1:87b2c64d84d8b5458eab7cc5d43dfec82b1f2ca3 + pristine_git_object: 1ce58e745c6403bf784132cfab38c9e36d39b45d + docs/models/create-schedule-item-on-increase2.md: + id: f67a32346042 + last_write_checksum: sha1:e872791dd531cd3968a06116a906593b5e514fbf + pristine_git_object: d2bc67b4b54d8633afaa9196f3cc2698db325111 + docs/models/create-schedule-item-plan-item2.md: + id: fc6ef0a98d3f + last_write_checksum: sha1:d6e80fa34328cd60018b91b574b61192218dd710 + pristine_git_object: 148872730ff4e2b5a3941fcbc7309ccc130dde5c docs/models/create-schedule-item-price-interval2.md: id: 8d7b2b0b0b88 last_write_checksum: sha1:01c30f8bf8bd2dccfcc26b8f9cdab56ea452c9aa pristine_git_object: a1f4d64ee66e3d260dc8218f6adb8cb58d86317e - docs/models/create-schedule-on-decrease2.md: - id: 4bf1baa68451 - last_write_checksum: sha1:d2ab3a9f5ab3760319f6b59735ab0f4ec68c5aa5 - pristine_git_object: 08c9906a105384e868dc8b0f05a3fe78a0bebe81 - docs/models/create-schedule-on-increase2.md: - id: cc8300ceeb79 - last_write_checksum: sha1:48571ff60e6d32ebfd8b7ebc7e52e3bae2f98a2f - pristine_git_object: 7b0ac6d95b3598e6fbbce518ac22c4d9c7b8b6c4 + docs/models/create-schedule-item-price2.md: + id: 45491e4f1f8f + last_write_checksum: sha1:5bc8adf381b434bdd153318d2f6e74c5db56231b + pristine_git_object: 25c3a34aa78363d8427907eb9b2306645675886f + docs/models/create-schedule-item-proration2.md: + id: 632ba954d331 + last_write_checksum: sha1:7f5afaa8d299ad00c12ac79153a7ba440238c69e + pristine_git_object: cacc82ba4057c17ed7eb8704de7d641c3f3ff4a4 + docs/models/create-schedule-item-reset-interval2.md: + id: 3c4a3a52b2f3 + last_write_checksum: sha1:cc374b7f9b24b0089032f1527273128fb9d2f55f + pristine_git_object: 1e648c052fe32ee2cfcba5e21a51ae22569b5bd8 + docs/models/create-schedule-item-reset2.md: + id: 37474c755f26 + last_write_checksum: sha1:3ab69167356ef28868acd8a55fbb69b4dd531d4c + pristine_git_object: 6cf596bf6202d96e37fa28a16d5e86f2d6aebfcd + docs/models/create-schedule-item-rollover2.md: + id: 8cddbe945a46 + last_write_checksum: sha1:5cc956f6e95ea50424eb19abb46225e3fa8a5836 + pristine_git_object: 8e42215e858dd57c2dbb0c7128780d992cea1f4b + docs/models/create-schedule-item-tier-behavior2.md: + id: 23c91c1c64d8 + last_write_checksum: sha1:08c43d0111914da0eab3bc6318bc44a94ef2517c + pristine_git_object: d26dda3bd0aad3abb60f981d9dc51f46068a428c + docs/models/create-schedule-item-tier2.md: + id: acb3247199c8 + last_write_checksum: sha1:3a33ac50eb8d29f54ea6b7bc0a625315c7d21370 + pristine_git_object: bca3b334b7533d050116450c3bdb6e734674ac19 docs/models/create-schedule-params.md: id: 9cfe8e156925 last_write_checksum: sha1:d1678ebd53fefef44a41ce6a2cef2f58486afdcf pristine_git_object: d393b27966e89cac70d86ee1f6cd99c0b943ed92 - docs/models/create-schedule-plan-item2.md: - id: 973e64524e71 - last_write_checksum: sha1:b88dd8af325321c41f175594e1eee893b5cd29f6 - pristine_git_object: 35cf9cc134a93e991573316797312e644622a815 + docs/models/create-schedule-plan-item-filter2.md: + id: c9f7f1341563 + last_write_checksum: sha1:d8b92b0f88063706106be3a066b1bb20d8f5ed37 + pristine_git_object: 91c597742ca51ea8d02724291e69cca96ab98026 docs/models/create-schedule-plan2.md: id: 95421d901e9e - last_write_checksum: sha1:ac947e970b6bf443115eb75b98064c1e76b371b3 - pristine_git_object: cfddb1a8db057c4561ae7fa2e3b331a44d2a3acc + last_write_checksum: sha1:c22035de8dfda09f510bf6a03eb2222d47afcd3c + pristine_git_object: 553452a584352f3e9a6d673b977fc7b0cbfa4c2e docs/models/create-schedule-price-interval2.md: id: c8864e651b12 last_write_checksum: sha1:f569c25cccb1b8f4eed7d44b1c636ea49fc086bb pristine_git_object: 89c1c668f2a4ef702c3f687f963a0f87c7149a8e - docs/models/create-schedule-price2.md: - id: 1a6b4d9bda9b - last_write_checksum: sha1:920275292bb95f3c968bf07ee7d8eb31571fae3a - pristine_git_object: 1ae14098e83c3b1883a24650d99e78fdcd6ec12d - docs/models/create-schedule-proration2.md: - id: 4e63391e30d6 - last_write_checksum: sha1:475648f353acddc53a130d405d2a9e15cf97df0c - pristine_git_object: edb33dbadbbd29fe925a7d2aa6dbf0f820018340 docs/models/create-schedule-redirect-mode.md: id: 4414e1893b11 last_write_checksum: sha1:a370027bd5e56f88c2223275e6131bfafacc6e44 pristine_git_object: 3818a6cdd82866fa3e1e8185168fb984be60f4fe + docs/models/create-schedule-remove-item-billing-method2.md: + id: 93244996220b + last_write_checksum: sha1:e46ca38f31347afb326fba64e25d265a7e867112 + pristine_git_object: fd631897c1e7cc92fb2f5bcd2e82d02420594af7 docs/models/create-schedule-required-action.md: id: 705cbed85eb6 last_write_checksum: sha1:eff121bd0dc7a48460779906cfc7859f814506b9 pristine_git_object: 944922070cfb22e2478065f5f38190431732cff8 - docs/models/create-schedule-reset-interval2.md: - id: f8e0d7a4220f - last_write_checksum: sha1:c3c4a83165527245c7cbd66a793800cb37772de3 - pristine_git_object: c1455f7a32a9d353b121e8fcfd71e067fff04e50 - docs/models/create-schedule-reset2.md: - id: 7d4c10966f2a - last_write_checksum: sha1:352cecf420c7fd52f6e54394f214429f4aa7d11b - pristine_git_object: 56b9aed59bc8d8a7ed174239fed366ca469da437 docs/models/create-schedule-response.md: id: 59dd046fbfe1 last_write_checksum: sha1:a933047cf48a6e4cb76a3c0a95c38cc0312d4be1 pristine_git_object: 16d07a58ecc64d9a5151b01c5aca483c1e557f97 - docs/models/create-schedule-rollover2.md: - id: 33e8d3bda5e0 - last_write_checksum: sha1:e8bea542f723633c4d8b9b68cbd1772ea2e319c1 - pristine_git_object: 772cb2d24857bb6ef8839be1d4ba89bd82ae583a docs/models/create-schedule-status.md: id: 7491a7693702 last_write_checksum: sha1:64126d4f7c9fb6e1cd0c2652a4928cb846fc8af6 pristine_git_object: 65113db9b9fc4600c69044384a6915f198d167bb - docs/models/create-schedule-tier-behavior2.md: - id: f1c084cb6d0b - last_write_checksum: sha1:ce3ac170696ae6a5de36a813e7599969fca793a4 - pristine_git_object: ed29fbfc522aaf04d8a13ebb5cf08c3e7172886b - docs/models/create-schedule-tier2.md: - id: fcfbba037091 - last_write_checksum: sha1:e8d661a68c3514183269ef2d3f15ea28363384e8 - pristine_git_object: 148662cdd12923b4c0a96a6036758a1468edd2ac docs/models/customer-auto-topup.md: id: 704634bf6875 last_write_checksum: sha1:14f5963eb3b886fc011541227bf06e681a08c527 @@ -1273,12 +1425,12 @@ trackedFiles: pristine_git_object: febcb407c0d5f4ea285a6eb9a1787d34b2bafc1c docs/models/customer-feature.md: id: d90579a4e5c4 - last_write_checksum: sha1:50870acf6a5fb9210beeed5c2c81e91a526d9c48 - pristine_git_object: f38cdfd6ea5b5adec63eb912ca1339d50097614d + last_write_checksum: sha1:85f53c2a2382fab6ce4cd052bf1afbf092cf600b + pristine_git_object: adc2d89530ce42931d6f7e0004236cb755d8fffe docs/models/customer-flags-type.md: id: 42f5241c0bbc - last_write_checksum: sha1:eb1df3c4eafceb4af876a37ca91b6b0967abee2e - pristine_git_object: f5d8ac258d33cfd15ff581872aa4c0be9e9856a1 + last_write_checksum: sha1:c75e3853e821a19050e7e2982e881696755303b9 + pristine_git_object: 437377f0b97f173b04d271a38f1328e56fad41f7 docs/models/customer-interval1.md: id: ce6187bdb5cf last_write_checksum: sha1:19a9ca362538f7b40859f670f9c6a8953bc65f05 @@ -1287,10 +1439,18 @@ trackedFiles: id: c41de4b1ad77 last_write_checksum: sha1:d78ce21f2220841e9e038489cdebed3585072e25 pristine_git_object: 42c351c54432d2ddd8733cdcf3440c0e43011c82 + docs/models/customer-model-markups.md: + id: 87a918122a22 + last_write_checksum: sha1:091b6e9769ea6ae59b5af450159f0e51f38145a7 + pristine_git_object: bc228016ecd45f09f1fb8dcf06da7f6772851975 docs/models/customer-overage-allowed.md: id: 42724f0842df last_write_checksum: sha1:eb4f1f1a1d5ec1b88a361337fbeecd4554dd981d pristine_git_object: f48291d4ecc3c98349c7b5d108c069430fa171ad + docs/models/customer-provider-markups.md: + id: 2e38fdc8f655 + last_write_checksum: sha1:381ca0dd23cac0529d51e45ca8f668519335f04d + pristine_git_object: 4f0d6814b183da7c7ea62f2f92c51a9a0b18378d docs/models/customer-purchase-limit-union.md: id: d4a2c111790d last_write_checksum: sha1:1ab1abbf3f4c3f9233fb7161cec6e615751622ab @@ -1327,14 +1487,6 @@ trackedFiles: id: 42ac97d31359 last_write_checksum: sha1:a21b8bcc00e9d6ac52626858f50a6714ec084b3f pristine_git_object: 2a5ab5346a272acf494afba00d359d37f28341e2 - docs/models/deduction1.md: - id: dd8575ca5e88 - last_write_checksum: sha1:35b201071650334e0cabec831c195bc937e41216 - pristine_git_object: abb75be9e6caa2760a940d74b2bfcf9818223b50 - docs/models/deduction2.md: - id: 3718ef0a5a82 - last_write_checksum: sha1:54a46a816466b4a9ab5b1181d8833827fb33fb0d - pristine_git_object: abfb70ac26387f3e8637d9f7a2b7311b249493de docs/models/deductions.md: id: 4c3443de5c70 last_write_checksum: sha1:1493e076176891427755858c96d2a1917dd5ae66 @@ -1469,12 +1621,12 @@ trackedFiles: pristine_git_object: da2af4c5d2803a58d4e4024c4f932bf98d81f47b docs/models/flag-type1.md: id: 15d64d2ad155 - last_write_checksum: sha1:5c3e6abbce2cc1e2031dec3753e1b772c33c2be2 - pristine_git_object: f89e223ad297c3a58ae898e13db7189b6dd7f0b1 + last_write_checksum: sha1:7d0073a7a32f5d52c643c23c7cfa06b1f087e031 + pristine_git_object: d373184fca21014ffcbde030667b66af35271bb5 docs/models/flag-type2.md: id: d17b05a3a757 - last_write_checksum: sha1:06900ede6a3c179425a57395ccfdede8c6cb05cd - pristine_git_object: 35462df3dbfed697a499518ef17b3a0a7e18a115 + last_write_checksum: sha1:7e699f337f17ec059d76a5138fc77cdffe3ac70d + pristine_git_object: e98e0d4c41f9b1b41b2309b6a02c699b8414065d docs/models/flag1.md: id: 56f975d669ad last_write_checksum: sha1:76d82de9c6f767225df3d503c13ed5b2ff9235a1 @@ -1549,12 +1701,12 @@ trackedFiles: pristine_git_object: b164a6d76b01d4caf3ee8c15bdc939fc37dac07f docs/models/get-customer-feature.md: id: 2c39993c881a - last_write_checksum: sha1:0ff95d881e1d30b1455b69939b1301f0f3a2a9aa - pristine_git_object: ac3bd44496cdf8662cbed7aeff4459e6f41dcc31 + last_write_checksum: sha1:956def0e76402369ce96c5262536b10a604d8e6d + pristine_git_object: 44efd4332d200402d403f27ae52a6f25d40dbb2b docs/models/get-customer-flags-type.md: id: 01974cc324fb - last_write_checksum: sha1:710a8fb75a959eefbffd519d82ff1ac8b81ca95e - pristine_git_object: 8bf1e49fb427a044f2c048ef9e17cec6992cbd37 + last_write_checksum: sha1:f7b918dd286ccb7001a4377707520e50a3c3ef13 + pristine_git_object: 0c2ac61e0e697d08bf47c9e3c5500f471106eb12 docs/models/get-customer-flags.md: id: 040b82101a08 last_write_checksum: sha1:b7d53fe1dc762bec8ad3aecfd13be9e2293dd614 @@ -1575,6 +1727,10 @@ trackedFiles: id: bf20f42ef394 last_write_checksum: sha1:3a151d13b72f55285119f103df8733ff9150cae5 pristine_git_object: 18d90598156b0adec765c6f47c7a7bded72cbb76 + docs/models/get-customer-model-markups.md: + id: 51241eb778ce + last_write_checksum: sha1:7872da9f458affe8c9a80919b1b97292651f1f6d + pristine_git_object: f63079da7060aff2a71cc00a5c4a631937d7a30a docs/models/get-customer-overage-allowed.md: id: 190455582bcd last_write_checksum: sha1:c5166989412040740be322eaf4c9faf9c26e20c4 @@ -1591,6 +1747,10 @@ trackedFiles: id: 385d1f246980 last_write_checksum: sha1:ff5155287ed0c747bf2a4d4f8d0855574079b97c pristine_git_object: c6fa973fd8850e169d37ac260d03c2bcaa58d13b + docs/models/get-customer-provider-markups.md: + id: 95e67af88dc8 + last_write_checksum: sha1:5a7912850d42896730a53207826765f9eb56f6b2 + pristine_git_object: ff3ed0f6ac215eb468227cc3da9551a767be80c0 docs/models/get-customer-purchase-limit-union.md: id: b5a61b56e410 last_write_checksum: sha1:3afff89988808f3ea82bf63e13e6d99be3bf2a74 @@ -1685,8 +1845,8 @@ trackedFiles: pristine_git_object: 8dcd9a68d2142e9d5fc73e97b0f6529641c45cf7 docs/models/get-entity-feature.md: id: 61710e83f226 - last_write_checksum: sha1:ae045ca5a705c07a4cf28d8e8794682ddcc53c24 - pristine_git_object: ea37a2c9c3fb7dcc0521dd22e40efd1adf08e1d9 + last_write_checksum: sha1:3c1f4df8c215b4b4d349872362b1f3264d8ff4ee + pristine_git_object: 0aa18a511fa6ab6f13756b1362053960c3e1994e docs/models/get-entity-flags.md: id: c5588533dc8c last_write_checksum: sha1:2a5c6ba14db30470cd28d7bc479ac09d2359cc45 @@ -1699,6 +1859,10 @@ trackedFiles: id: 37da93d0f360 last_write_checksum: sha1:35428f5bd3c09ac37d06c16a5003d5d64b309492 pristine_git_object: 80dd6e72adeb66d3be7528cd52b83fb898f26b08 + docs/models/get-entity-model-markups.md: + id: 7482b7f0005a + last_write_checksum: sha1:02d110d7553bdf5ab37000d516533293d991461f + pristine_git_object: 6aa5337c0d5671c0b37ac24c32ed171bbaa04af9 docs/models/get-entity-overage-allowed.md: id: 22f12d3de970 last_write_checksum: sha1:9ec683a3769520684f4c0a2e02fe554ba8609d8d @@ -1711,6 +1875,10 @@ trackedFiles: id: 3921d2e48596 last_write_checksum: sha1:2a123a8e0ec49766affde3647ff4388790ce1015 pristine_git_object: 5da8805280446452234efa9e5e18408775714097 + docs/models/get-entity-provider-markups.md: + id: d86aa2544951 + last_write_checksum: sha1:40ac5220df8ee73da28d93031479cf3482eaa00c + pristine_git_object: fc1a64fe75b10f9223035198a12a1355a81a8ab1 docs/models/get-entity-purchase-scope.md: id: fc36a3fa821d last_write_checksum: sha1:4731bf8cf5be4e596228978593c7bc3d8d447d33 @@ -1745,8 +1913,8 @@ trackedFiles: pristine_git_object: ec9c50c6e7391281e089f926ce521c7467d58435 docs/models/get-entity-type.md: id: a4327c9850bb - last_write_checksum: sha1:3b87a7bd5465a9fdd2019145225799140a136436 - pristine_git_object: c464e454b6a0e4a3ac2c1c2ffd949116622d6c70 + last_write_checksum: sha1:79e247ebdfe8978504cea20007c74990c275019f + pristine_git_object: a32b9c0a91463ec84b96632b7a799568a83523d4 docs/models/get-entity-usage-alert.md: id: f9d9e7763fc7 last_write_checksum: sha1:867ef5482cd9eab6ce71d455d9a7a7907fbb9218 @@ -1763,18 +1931,26 @@ trackedFiles: id: 0e54b4dfbf93 last_write_checksum: sha1:8614790ec9cf60361ae8233b37e8f9fe8a209625 pristine_git_object: f0080bd69cac26d0071e7a54809e7c52dfe07753 + docs/models/get-feature-model-markups.md: + id: 43c91f7c8f74 + last_write_checksum: sha1:54c03ad825aae61fe3f062845fe64603c38abada + pristine_git_object: a7fdfa33774c5a222437fc4441ef9a3e454f91f4 docs/models/get-feature-params.md: id: 0aa3a7083647 last_write_checksum: sha1:88379482a911d56665178d5e3e45b68a59b17b0e pristine_git_object: 0e300c7c95564dbb2397515913a0de676e0757a1 + docs/models/get-feature-provider-markups.md: + id: a51db4c2d031 + last_write_checksum: sha1:11c20f69576779396f27f0d7ebdef7c88be7e010 + pristine_git_object: a6c2b5fe15fac7f368970b7d0c131673c9d63a35 docs/models/get-feature-response.md: id: 5ad49696a299 - last_write_checksum: sha1:40c5721b50a30a1dfd82966c9d54f5df060dec48 - pristine_git_object: d16228318cf121178c15d0e0856e659c946c6e9c + last_write_checksum: sha1:8a4b3b807ce738285f598000e89b8df19f35e99e + pristine_git_object: b0c735bcef459854c0b301f40c7920e82e14afeb docs/models/get-feature-type.md: id: adf0af07d3cd - last_write_checksum: sha1:b372f582c4ae8bd51c95c8018aaf03142b47d35b - pristine_git_object: e0b36186fee578620f2b18bc58b518541b35b7ea + last_write_checksum: sha1:8f2decfed9f7deea05678dfd7009b073f6938f28 + pristine_git_object: 8f240bde6709a476ec744ba6216e1f0af24e113f docs/models/get-or-create-customer-auto-topup.md: id: "673761036094" last_write_checksum: sha1:82829d5ba5755f620fd2e571c254d3ef6eadf82e @@ -1873,8 +2049,8 @@ trackedFiles: pristine_git_object: 1e3256b2142d09290462243145af29ca3e0e1590 docs/models/get-plan-item-price.md: id: 861928cd4da1 - last_write_checksum: sha1:ccb2da4e47d715eef9c763ed5bdd1a5c1c1c13be - pristine_git_object: 35e6b379e9ab74549351aab4d59a61a13a7a1a98 + last_write_checksum: sha1:dae51ad53086e7d556ee948fd1b13e4f9a2f226e + pristine_git_object: 83d7ecc7e2318313a31814933b8829a2e1e33cf6 docs/models/get-plan-item.md: id: d1f1aa0f9dc6 last_write_checksum: sha1:2e0a987df242117cc9b9809e4a8b3ba89bb8cce4 @@ -1927,10 +2103,18 @@ trackedFiles: id: f3718ee31687 last_write_checksum: sha1:7fd442b40cca24b5b2058587b86b8343791549d1 pristine_git_object: 4e14d9501022fe440e86ccb830a2caaf90352859 + docs/models/get-plan-tier.md: + id: c466d2316ebd + last_write_checksum: sha1:6510dce214e66606d7114b0f588dc1effbe7df9b + pristine_git_object: 36b8ef1861f4ec1bb38a584e3212e788f842695d + docs/models/get-plan-to.md: + id: 90434fb9c1da + last_write_checksum: sha1:a6bc90910fbd84449aee946bfe08835049643304 + pristine_git_object: dd7f0b9053cb9e4ad851d1e9b50fb3c9aa13448c docs/models/get-plan-type.md: id: 5300ef539ed8 - last_write_checksum: sha1:b334efb80b4e13e356c43248994d2f2640ffcf27 - pristine_git_object: b6ff1ca2773ceb3a33da7acc3bf2144f2823c16a + last_write_checksum: sha1:750bc565ed7ebeabd17e360e4a53f7128155abf8 + pristine_git_object: 521ca45eb8418ff72052849b09e1c7cd8f64ae27 docs/models/get-revenue-cat-keys-app.md: id: 8ff879eb81c5 last_write_checksum: sha1:23379e8e1717339e91ff62f63c9a5115c03a8645 @@ -2013,8 +2197,8 @@ trackedFiles: pristine_git_object: e8319e97653a42eb67a6e54d154471cde8182df8 docs/models/list-customers-feature.md: id: e66feeb7ba38 - last_write_checksum: sha1:6d64076d70976a5807411ec74edf56dddae486e8 - pristine_git_object: 279063fdcbda1617689464861464166602be70ee + last_write_checksum: sha1:37c453bec76c11b3e10f1b6ef862255a9fd2223f + pristine_git_object: d7aac1e126db54b9cfe334b0a99c17562d624355 docs/models/list-customers-flags.md: id: dca06a30b2ba last_write_checksum: sha1:63d353e7a5aa0743127df5761a621545c9d79056 @@ -2035,6 +2219,10 @@ trackedFiles: id: d9cf28563990 last_write_checksum: sha1:c2ff01465c1858045a71a22dc194e3a4b76f6709 pristine_git_object: a42d67674a252a14b8287e90c0044ae0629e8c8e + docs/models/list-customers-model-markups.md: + id: d73f658af572 + last_write_checksum: sha1:4e9c4d3647e34db0b2860c734af481789c0b46a4 + pristine_git_object: 28a4f7a7850f623a0dac2d5f3e91f85f52cb108b docs/models/list-customers-overage-allowed.md: id: b9462902bd9f last_write_checksum: sha1:845ad982b9448b34334a6f6b934d69fa8c108306 @@ -2055,6 +2243,10 @@ trackedFiles: id: d9aa45cd04b4 last_write_checksum: sha1:33cf1491f86b915b7a22f558899ff7c89ee33548 pristine_git_object: 95e8d2d43522346d8c9c4735e2a0d0bc09f3e166 + docs/models/list-customers-provider-markups.md: + id: 31590d4a5aec + last_write_checksum: sha1:c13b9422b378b57d912cd1236ed65a0d3864e9cd + pristine_git_object: d6dd7aee89e3317968e1ebd4ad941d0d5b0b3d82 docs/models/list-customers-purchase-limit-union.md: id: 3df129fe3c7c last_write_checksum: sha1:c9ae462b1d255d5ce28ec4a67c5fcc60a88245f0 @@ -2113,8 +2305,8 @@ trackedFiles: pristine_git_object: a7a6b894d40f6da939c1cb9b8e383cf81ec1665b docs/models/list-customers-type.md: id: 0800cbc8ca9e - last_write_checksum: sha1:ce2e02b77c93fceb4112a25522981cb9ad28da4c - pristine_git_object: a8572a253eaff23800e32b97966b27024a175d00 + last_write_checksum: sha1:00faabfcfec121eee59092562396e400450f8b73 + pristine_git_object: 9bb572327dbc280e8cf942cef92bc664adb7aef4 docs/models/list-customers-usage-alert.md: id: b3eb5cb1a703 last_write_checksum: sha1:ad5e43b83167904c88f140e9fa2f78b343839871 @@ -2141,8 +2333,8 @@ trackedFiles: pristine_git_object: 0ebe3599d266597662d5c9eb15524bbbeda68b36 docs/models/list-entities-feature.md: id: 9527b485845c - last_write_checksum: sha1:e280a8a82a3fda5bb787bc537233310fcf0503d5 - pristine_git_object: 42fbf0a8e44e2bbd1d474aad8f30a937e6a8498b + last_write_checksum: sha1:38e5327b4877b6e144cc8e3b9fb00cbe3e82ed11 + pristine_git_object: 8681127569251b79aef113ab446fe9ab5f7e8a70 docs/models/list-entities-flags.md: id: 574691a717f0 last_write_checksum: sha1:4e90e9b2814fbd3ed7dbbe14cfd5cd027e08ac55 @@ -2159,6 +2351,10 @@ trackedFiles: id: b4d22adec43b last_write_checksum: sha1:96068bb713a592fba163934fac1e214090b2df7f pristine_git_object: 62fad9774edcdfc08d9f3d2a895476f85f2a86f4 + docs/models/list-entities-model-markups.md: + id: 26a3b68f32a1 + last_write_checksum: sha1:e8fa89be8e610336505fcef14437d7bae84bf698 + pristine_git_object: ff6fc9719489e5f44db4c99105bb0e0cc09bea09 docs/models/list-entities-overage-allowed.md: id: 5c131f1d7f83 last_write_checksum: sha1:f2b989523da066ac313ffce2a2bc72567f9b116a @@ -2179,6 +2375,10 @@ trackedFiles: id: 8eed7e174881 last_write_checksum: sha1:9c9afb3d43be7f860d4d6d885523ed96c04cea23 pristine_git_object: 224d058e72082f0f48951b564acd3362331d63d1 + docs/models/list-entities-provider-markups.md: + id: 94973ecf5d0a + last_write_checksum: sha1:e62fcf2b24140f26048ce275167c131c612374e7 + pristine_git_object: 58be884c988fdb71952d491948e24823dce55da3 docs/models/list-entities-purchase-scope.md: id: d5d5b051196e last_write_checksum: sha1:eff4573688a4ee268c16d83b09399caea3119c20 @@ -2217,8 +2417,8 @@ trackedFiles: pristine_git_object: 2adf3ef8748016f37626574fb4c5b2436f3149c6 docs/models/list-entities-type.md: id: 1783d1cbae6d - last_write_checksum: sha1:d667037f58ec7558a477021ff5dbe22a573c7d8e - pristine_git_object: 58f26966813c136021eaaae36f19cf41474e7387 + last_write_checksum: sha1:59fe3ba25c75ca04bf9b87be06d6522836b1e8d3 + pristine_git_object: 8022fa5ab176e57bf1ae396e9751bde35dfa61c9 docs/models/list-entities-usage-alert.md: id: 6d54d34df380 last_write_checksum: sha1:d2fdc2da1528d7046dc8b9c7bc8d5100115dab2e @@ -2269,8 +2469,16 @@ trackedFiles: pristine_git_object: 50e2a4afc6a9500200b1ed85a3ee84a3e34a5d2b docs/models/list-features-list.md: id: 3e696eaa96bf - last_write_checksum: sha1:0ef596e40d8e83303406321d693cedddbd3d6052 - pristine_git_object: 81f8e8cd00328e0624c7d9d19b09d9f304d39aae + last_write_checksum: sha1:c714d75f54324e6338e0dd0d197f030d429ce1f7 + pristine_git_object: 4d8ea78f530012744e68036e2c02be57fabc5fce + docs/models/list-features-model-markups.md: + id: 06c373eaff6c + last_write_checksum: sha1:e266aa27287a403833041f7e482651138e7fc91e + pristine_git_object: 96be18ad41a67e73370ae75b42de94285f3a06ab + docs/models/list-features-provider-markups.md: + id: c7db5d84e7f5 + last_write_checksum: sha1:d53f73ba80183a882604e57104769ae1ee42368a + pristine_git_object: ef574fabd4be2c33380841f6d6b2b24cc65b4437 docs/models/list-features-request.md: id: e7874655ad35 last_write_checksum: sha1:f99600008adcdd1571127e50301b326504252cdc @@ -2281,8 +2489,8 @@ trackedFiles: pristine_git_object: b4ab86d4b5ca500d8869996c31423005554d3bae docs/models/list-features-type.md: id: f90d587b8227 - last_write_checksum: sha1:92e406420f065240cc86249766dd22cfecdbcae1 - pristine_git_object: 2d4d45ea57e3eae7530b2907f918e7f021f70017 + last_write_checksum: sha1:7a5099240b46d5cd1a98e0836105458f993c109d + pristine_git_object: 7b2557079e8938f22336981df4e567433dfd0e4e docs/models/list-plans-attach-action.md: id: d5b7f6e0bc00 last_write_checksum: sha1:15a5241965f68db426a82537358beb64cad4cd2a @@ -2321,8 +2529,8 @@ trackedFiles: pristine_git_object: e80bf3060e27fbfd2b1aa6400aa83dd8faae98d2 docs/models/list-plans-feature.md: id: 2dfe904a0b39 - last_write_checksum: sha1:a28c0ca698ffd23647239ac6c2d7d69479c6d125 - pristine_git_object: 9607cbb75c6299d1f006520a2369adc10f558d4d + last_write_checksum: sha1:96e76833c868e6591db14cdde984fc5c24bdf314 + pristine_git_object: 2a290addc79a98ed39762724bf4139c6d45ad3ec docs/models/list-plans-free-trial.md: id: 3ce407d3abd1 last_write_checksum: sha1:6d59eb275db2e98172efa278153dcf0c6121187c @@ -2337,8 +2545,8 @@ trackedFiles: pristine_git_object: 534ae703c77698677e330b81365b9db16e57ed9e docs/models/list-plans-item-price.md: id: 2adf6736dcce - last_write_checksum: sha1:83632f96eb957315f959b6ebc8ee63e575c5c9ed - pristine_git_object: a3fadbfc303caa1b6db74d37c263959c64717857 + last_write_checksum: sha1:a4c66984e7764ff8351d8033a6bd9da094800c78 + pristine_git_object: a45a17984c6c2810c8355f8f4718f2f277aa7bfe docs/models/list-plans-item.md: id: 320df11123d9 last_write_checksum: sha1:d210059b6169b3d4ae9f1edda037128c36c96683 @@ -2395,10 +2603,18 @@ trackedFiles: id: 3e6b30e1aad3 last_write_checksum: sha1:9f8478155d4f98a1156bc3364baf32b69d128e9a pristine_git_object: 924fead782b1c352ecca3fe8980e36c0fd22ebd7 + docs/models/list-plans-tier.md: + id: ee46ee372f11 + last_write_checksum: sha1:fe8c8d433aba87615a7d0c16f8c35bcdff65d63b + pristine_git_object: de7b22e22b1dd9f1145714010a7fd77d7114ffcb + docs/models/list-plans-to.md: + id: 219811266af2 + last_write_checksum: sha1:ae21918d904d84961771ab388c897c8c92b38d61 + pristine_git_object: e8c79419fa63be74a687c7518f6d9b3b0a8c38b4 docs/models/list-plans-type.md: id: 902fdb41bbb2 - last_write_checksum: sha1:d193eb535e717935365e99a4eac285d50cfd0288 - pristine_git_object: 8f2779e720e50be967f02698e4e5aa8b89b05e2e + last_write_checksum: sha1:04e8596440df403574325e13e34b02715cca2857 + pristine_git_object: a72586266f5fc9b555da1457d453c9eb50955595 docs/models/multi-attach-attach-discount.md: id: 3536a21c4f47 last_write_checksum: sha1:2dfea27b10b93fd68105a4ca58a94f4d4bcf1c1b @@ -2493,8 +2709,8 @@ trackedFiles: pristine_git_object: 6652a9165edddeb2e349c7ecb1425da08dc93b52 docs/models/multi-attach-price.md: id: 24b09376a9bc - last_write_checksum: sha1:5093f3f7d158ce927d9703fa90a6ad05d2b58879 - pristine_git_object: 1f593609eb2e55d6a216ce47c5b4657e9e2ddd80 + last_write_checksum: sha1:e19d98992c4607cd2ebdf1ad782e8411068b0ef2 + pristine_git_object: c83070a24a8afbe0cf906aa3fdfb53a8a63bcd38 docs/models/multi-attach-proration.md: id: 6086395ced3f last_write_checksum: sha1:bc0871bfe80b515731f2fb7daaa383e4a80c921c @@ -2609,8 +2825,8 @@ trackedFiles: pristine_git_object: 7b445ddbfcb5f1eaa4d64460de684010dbcc026f docs/models/plan-item-price.md: id: 8b84292b413d - last_write_checksum: sha1:0c0e871f76cffa0a4c3c1ee3d9adce3281f523d9 - pristine_git_object: c56ce41d28c9f8d94b77be11a15c672275c49d43 + last_write_checksum: sha1:a9d1c76045fe2e0324ee812948fe5282727076c4 + pristine_git_object: 4a9fd95017db3b2eb01dc61a1746234a05012f2a docs/models/plan-price-display.md: id: da4df629d91b last_write_checksum: sha1:bad027df2d61ccfabac1efb14fb38a05d40bf479 @@ -2647,10 +2863,18 @@ trackedFiles: id: 460cbe1aaa95 last_write_checksum: sha1:632bab29fca02e35bbfc910cc2ca767e5245bb97 pristine_git_object: 2b00459690aac0d23f912cc3408e1aea3784b8b4 + docs/models/plan-tier.md: + id: dd7f4ee452da + last_write_checksum: sha1:228c68fab301d3648bb11b185fd613b53d4c7fac + pristine_git_object: f16f46122cc547466672612ff0b7870645170c36 + docs/models/plan-to.md: + id: 265b5d85de02 + last_write_checksum: sha1:73ba2dc4aa70a0e8dd181c625d49e195512a5aa8 + pristine_git_object: 7a48160543abf02c3620bd86ccb7150bc32dad8f docs/models/plan-type.md: id: fcf04293a720 - last_write_checksum: sha1:ecc184cb1098804970168b1cc83516a04107f2ff - pristine_git_object: 7bc7590d5b2edd57dc26811f4a9ce9e60d4c02a3 + last_write_checksum: sha1:fb29d68b9b18f6d08cbf8fd58dc5d2decbb6fd64 + pristine_git_object: 8bdd9a4d320831aad34a067315c3c07bf3fdc101 docs/models/plan.md: id: 900c4149ef4b last_write_checksum: sha1:cda2b09a51edc8803af0e3f264958871d9453f8e @@ -2681,8 +2905,8 @@ trackedFiles: pristine_git_object: 8d3d46c64cafd8aceb89cacaee594ee6ed626b30 docs/models/preview-attach-add-item-price.md: id: 137a9fa0ce51 - last_write_checksum: sha1:360990b661fa3c36055119d1e489773577790e23 - pristine_git_object: e4b16b9064075fcda4476129d5b580470cd236e4 + last_write_checksum: sha1:1d80900e0b3246c4507d251cf79766f94ab48f64 + pristine_git_object: 5e6429f22cb990eefd9c48ab91754ac4c90e609e docs/models/preview-attach-add-item-proration.md: id: a0cc9f711c84 last_write_checksum: sha1:bf1bea10529922e82606fc15e83bb4e86e10b049 @@ -2737,8 +2961,8 @@ trackedFiles: pristine_git_object: 64b48d4b9d52595ee992034610b5a04f2d11115d docs/models/preview-attach-customize.md: id: 56b1da317d34 - last_write_checksum: sha1:a11a7ddbc4c1049968ae281ce83c6d4c0e4f965c - pristine_git_object: 32340f6a26d8d0e0e82a06b9f6ba906d7f8eb106 + last_write_checksum: sha1:6d06aa9d02c0513d61547c20d7ba373f52d00da2 + pristine_git_object: 175e8a4f7bfe9b53204e1978290bd24beeaec388 docs/models/preview-attach-discount.md: id: 61e779cf6cd4 last_write_checksum: sha1:53a0ff16124f132a097dc2feffcbded47b54a620 @@ -2767,6 +2991,18 @@ trackedFiles: id: 562e0f8189a4 last_write_checksum: sha1:eb23a93c214d9ff39f5381c4dbaf3167132dc2d8 pristine_git_object: af005b7366d908ba80f2f038ce0b5e834a4b40cd + docs/models/preview-attach-interval-remove-item-enum1.md: + id: fe37528ce59e + last_write_checksum: sha1:6614ef244e0a104f6af9e7ffae98f8484e9af226 + pristine_git_object: a596750d612d8a8907f45a9cab1c51399c7d6e6b + docs/models/preview-attach-interval-remove-item-enum2.md: + id: 4a6b4417943c + last_write_checksum: sha1:0b6dace9134ca0e757db21f06b3eaaf3a3b40698 + pristine_git_object: c11bd1b0bc82a06fe43d9bdcb2c542a67c12569a + docs/models/preview-attach-interval-union.md: + id: 98f29668cbbf + last_write_checksum: sha1:fbd98348e53e09c6ea79fa51041422d5b0fc07cd + pristine_git_object: 982a04dbdd62396795c18721d59c64848766b57e docs/models/preview-attach-invoice-credits.md: id: 498ccecd9cc1 last_write_checksum: sha1:4d49286e5b00800a0da5d003e9974531165e0ab0 @@ -2801,8 +3037,8 @@ trackedFiles: pristine_git_object: 4d66328a896d1d902027f2afb31f643980df8a75 docs/models/preview-attach-item-price.md: id: 7198a3dcdecf - last_write_checksum: sha1:efeaba736cb59a2f4bcc294c54239c382f79068a - pristine_git_object: a59604b4751279c6994f575ec3163bd2e91f2d66 + last_write_checksum: sha1:80d031b8536e158e65d563509c0c569d13905c84 + pristine_git_object: f2731f05b3ce6264f77030819263c73330c6581b docs/models/preview-attach-item-proration.md: id: d98e5de50de5 last_write_checksum: sha1:4c3875d685b5f6bbb79fbee959d55ad73027b5d4 @@ -2873,8 +3109,8 @@ trackedFiles: pristine_git_object: 63ceb85fdf9846b03b0b9cd5a3cba5c4a663adec docs/models/preview-attach-plan-item-filter.md: id: 090325aeaedc - last_write_checksum: sha1:05a4d6702fc21e161bc138843bd50b9b483a2f26 - pristine_git_object: 581768db0549271071907e99130bcb4ae0787e90 + last_write_checksum: sha1:d4e4fea4e56fa123307df52a0f409911c7822dd0 + pristine_git_object: 35a38f154af5067bcd7c4915dbe16f003ce7565e docs/models/preview-attach-plan-schedule.md: id: 216721a34465 last_write_checksum: sha1:c019a49a07da4814558783baf8f61a94a85617a3 @@ -2895,10 +3131,6 @@ trackedFiles: id: 4047f522fdf0 last_write_checksum: sha1:53c5c944fea8d6e1f1ab5c551410a380e3581838 pristine_git_object: 250153cf5df655046fc972a8fe1beee84dfa9323 - docs/models/preview-attach-remove-item-interval.md: - id: a5a052b91bc3 - last_write_checksum: sha1:a205a48b37a81fdcec04bb09ada33f74b9c81eea - pristine_git_object: 19d53c3cdeb5fa7084cf3c26c151d022daaaa8ad docs/models/preview-attach-response.md: id: f53481e3c4e9 last_write_checksum: sha1:c3f15af7a804abeffab3a8ad06795619f9c8a2ad @@ -3057,8 +3289,8 @@ trackedFiles: pristine_git_object: c53e901075934172ee880b22624c7c76590eedaa docs/models/preview-multi-attach-price.md: id: 4f5d1d27c433 - last_write_checksum: sha1:0ea143dbf92f407cf25a4d7ded471e8432918614 - pristine_git_object: 0e629461e97927187b65161de8adb582ef1a3608 + last_write_checksum: sha1:0d1d7e60a90e89d187fe7881d2b1baa6e8ff0780 + pristine_git_object: d6d487c4876ea7a47fbfa8a9937fa85617300919 docs/models/preview-multi-attach-proration.md: id: d4e3816ec9ec last_write_checksum: sha1:08fde5ace6ae31119dee347cbc815665f93e3653 @@ -3149,8 +3381,8 @@ trackedFiles: pristine_git_object: 36c404cc850cf26521f4c9e62d4c9d56a333a28f docs/models/preview-update-add-item-price.md: id: 3325a177b913 - last_write_checksum: sha1:f9d2e3a37d77ec5e0de66722cd8152996a9c90ac - pristine_git_object: 652b537d82757261a7381746cf439ec645116437 + last_write_checksum: sha1:c9cf089786a51106a1c967751f0c7fecc4a09879 + pristine_git_object: 5c4eb285123e976c1fcc6d45d84be2eb4b4f0f03 docs/models/preview-update-add-item-proration.md: id: 2df7ae6d181b last_write_checksum: sha1:36ea9ed6a6982046f9ee37bdb0689ba1af800879 @@ -3193,8 +3425,8 @@ trackedFiles: pristine_git_object: 0bb67d6c2ccae89db38e76b9f6e7ec2116dc8d6e docs/models/preview-update-customize.md: id: 96ff2b01f7eb - last_write_checksum: sha1:863399d07538515de925b9924bbbd14e1288b9f1 - pristine_git_object: cf71b0d9ce6e8f4183d53b7330a0aa6522a2004d + last_write_checksum: sha1:b5aaffe86c2e0dd134a59f440490d482324a7934 + pristine_git_object: f88107ee24a1a580e53d0e0d74db03e386341707 docs/models/preview-update-discount.md: id: 4b698ca5724f last_write_checksum: sha1:6f978223349f2643cd7dd7bbaf828252d608f86b @@ -3223,6 +3455,18 @@ trackedFiles: id: 991c5ea1f669 last_write_checksum: sha1:920caaafd7129726e8e7bcaebcbd87e6a15f770a pristine_git_object: f859f35f34578778e891fcd4ea8554bfd2e56aff + docs/models/preview-update-interval-remove-item-enum1.md: + id: ecbe676b6cde + last_write_checksum: sha1:fe51bbe3ccf549e345d85b82e2029f7a18c6d7c8 + pristine_git_object: 92a59eb3b4a08ea93d063a6e5526b8afc0530cfd + docs/models/preview-update-interval-remove-item-enum2.md: + id: 0a09993bf70d + last_write_checksum: sha1:ee746013015f63fb3e8825217dbd0d833d72f6fc + pristine_git_object: 8e5cb1b3e4f4cb2f335d71e722cef03422a080a8 + docs/models/preview-update-interval-union.md: + id: e3cca349307d + last_write_checksum: sha1:adb314017c3f1fc62e9a5df92955ed76bb2f2387 + pristine_git_object: 6d59e06ae421fc6e6e91b4c12aa7c87b9106447b docs/models/preview-update-invoice-credits.md: id: 96b6c5865908 last_write_checksum: sha1:c840c65078fdaf9ddb34e4d5825c3344365da297 @@ -3257,8 +3501,8 @@ trackedFiles: pristine_git_object: bd687b1b92155b4422db03a4194b600d96c99c74 docs/models/preview-update-item-price.md: id: 00b74df0e261 - last_write_checksum: sha1:0094cd8b49a97707fb8e33fc45fc711143323271 - pristine_git_object: 2228f5e2d574b777d7b7309d796e660af5bd4feb + last_write_checksum: sha1:121251f4ec4cd2b3748bbee5189a46d2bdd69151 + pristine_git_object: 6621b64c334e5701623f1dfa5a7e4d77415e9bf2 docs/models/preview-update-item-proration.md: id: 05e4e50a0da4 last_write_checksum: sha1:1257e435722d1a5af6d16ab9e4c22ec17a8b4fb4 @@ -3329,8 +3573,8 @@ trackedFiles: pristine_git_object: 01f553b36b40ee043b40b38fb487fb31f3fb0874 docs/models/preview-update-plan-item-filter.md: id: 28aeb1301817 - last_write_checksum: sha1:a2b9ba4ee24ae8d7c1748c4bf2ddc305d9b9fd21 - pristine_git_object: a4df8053ef4ee50aa64ef9a0aff9f7322dc1815a + last_write_checksum: sha1:079aca13099372b243afebd91443e47032ee2ebf + pristine_git_object: d9da9fd9f8a37f4b968ff762e3ce269b61228141 docs/models/preview-update-price-interval.md: id: b53e2aab5336 last_write_checksum: sha1:17e179f4dd4983f53ac679abff7a43f87a9767bb @@ -3351,10 +3595,6 @@ trackedFiles: id: 7abd00893e4d last_write_checksum: sha1:a301d6c9f95c5af543f1264bd16b5ea902dd163e pristine_git_object: 90fcafe95e124771e7c3a5ce6104321d2a60e9b2 - docs/models/preview-update-remove-item-interval.md: - id: 856df38c22ff - last_write_checksum: sha1:8c7b9e35cc94043eff204d59b17217642ef6318c - pristine_git_object: 4619542f6bebc3c09e73d35df8706dc5399f159b docs/models/preview-update-response.md: id: 8106134ab3a8 last_write_checksum: sha1:8c4be24d09682a3bda33fdf29f22253b7a5779a4 @@ -3521,8 +3761,8 @@ trackedFiles: pristine_git_object: 69658da85ef86853bac6123c5adb761502ac53f3 docs/models/setup-payment-add-item-price.md: id: e2ba3da917e8 - last_write_checksum: sha1:b4ddeb217e6807af1e062fc82b19aea98d6733ba - pristine_git_object: 82f4ba7a802d30a8d472b4d585390942598e770b + last_write_checksum: sha1:7915dfb127650adb4de5ffc573d879b22aa34a9c + pristine_git_object: cabb20a89cccc2ade46842fc0ccb7e244b7ccb50 docs/models/setup-payment-add-item-proration.md: id: 178d7d7c5837 last_write_checksum: sha1:c0166663e0f7cd7addee0758dd2986a51dc0e875 @@ -3573,8 +3813,8 @@ trackedFiles: pristine_git_object: da0bd393c71af8a6a36d1a17f0dbc16fc083c32b docs/models/setup-payment-customize.md: id: d99db9e22611 - last_write_checksum: sha1:9c1a2c24003fbee54a376405f018c37ad585ae60 - pristine_git_object: e1019dca645e603a18ed1de3e110f165834a1c6d + last_write_checksum: sha1:88cdb02c13752d16e68900f5785d3c58969dbe57 + pristine_git_object: a95bdfd519085f32340b38bef16b5ab31afcc504 docs/models/setup-payment-duration-type.md: id: b318c12dbec9 last_write_checksum: sha1:641175152a85bb66d26c7226f4227cc8bddb00fb @@ -3591,6 +3831,18 @@ trackedFiles: id: 10b1bea5c60d last_write_checksum: sha1:bed0598b5ae5518236246d63749795cc6eaab50d pristine_git_object: 6e60d36b1bcaa5302444c8135670a8d24e2efc24 + docs/models/setup-payment-interval-remove-item-enum1.md: + id: 253763c9a107 + last_write_checksum: sha1:c4b54c9cdfc0d1a18cc1c4265af2365e4021b8b2 + pristine_git_object: a290c4723d42958f7aaced07f26bca22263e4218 + docs/models/setup-payment-interval-remove-item-enum2.md: + id: 22fc9ec97209 + last_write_checksum: sha1:9399c5fe32e76bb9bbbea90609597415f9af570d + pristine_git_object: 18c0b88a5ae4a78ed2cd656a784e580435bcf97f + docs/models/setup-payment-interval-union.md: + id: 79592d881aa5 + last_write_checksum: sha1:0b93808f6669dc008d3c33f40b1a98e4b5a66352 + pristine_git_object: 4cdd725f71a08fed1da0e830552aa9e5ff5c3e1e docs/models/setup-payment-item-billing-method.md: id: 32b19840ba39 last_write_checksum: sha1:033484e85781b31dac9a4350011a56abc05b7e30 @@ -3617,8 +3869,8 @@ trackedFiles: pristine_git_object: 409f1f495ee50329a599b5c0128482414a8bc666 docs/models/setup-payment-item-price.md: id: 82a1f631e840 - last_write_checksum: sha1:0123eb664d816e4b993c161c0ac785c522878fa4 - pristine_git_object: abd00f3b12e4048abd28dc631d1acb920f9c759c + last_write_checksum: sha1:12815fabbb8bd7a4d87c8b05e5c0487526663286 + pristine_git_object: d8633bb85d25d1bbf9adc844597d4757fb14895d docs/models/setup-payment-item-proration.md: id: d880214e9e03 last_write_checksum: sha1:516a8601141cdee720f36d9ce2fc3df72fa02d4c @@ -3657,8 +3909,8 @@ trackedFiles: pristine_git_object: 0580c5188d874962e3625331d4d81872905e35c6 docs/models/setup-payment-plan-item-filter.md: id: 1e1e27721f1f - last_write_checksum: sha1:659ae69215e37f51108b1b667bd5fa44a1735918 - pristine_git_object: 308a493973aae0ff40be5e29ecdc2754198fbb9c + last_write_checksum: sha1:f53244eaa2690363dae0f386911943871036457a + pristine_git_object: ba52e209205e3411c5c021c9c1d1ee679ecbae9b docs/models/setup-payment-price-interval.md: id: c533f31c34cc last_write_checksum: sha1:1f7e5a0177cae1b371696bdc5124394f578844dd @@ -3671,10 +3923,6 @@ trackedFiles: id: 71080c21cd33 last_write_checksum: sha1:784cbf9747dc8299617e342b91a9b75a392b4bd3 pristine_git_object: 391389cf0d1d333ac15c443c6c5ae8543894cac5 - docs/models/setup-payment-remove-item-interval.md: - id: 951a199bdb10 - last_write_checksum: sha1:2224bb220ec46f49d3942cd25cb4823639022709 - pristine_git_object: 9e3a3bc9a91a99f4cf8e0ca6bbe7595c879ef4e6 docs/models/setup-payment-response.md: id: 9706acdb1f5d last_write_checksum: sha1:e11936ae1dd8e969bcbe07a7b69ec433da66041c @@ -3731,6 +3979,14 @@ trackedFiles: id: f4060c3b4657 last_write_checksum: sha1:db27b4c0beb158424465eff3298baec188ae6bee pristine_git_object: 07b601a1e1dbf1e05d82533970723f712e30b145 + docs/models/track-deduction1.md: + id: 2a7433f2a3ab + last_write_checksum: sha1:f537bbe62e95ce2647cd65657bb6edab1ddedfa7 + pristine_git_object: 3f5875b3bf831551404a46626cd9cfc6d374683a + docs/models/track-deduction2.md: + id: 980b4b0a00ca + last_write_checksum: sha1:e446aa208840ff72aa8b7ae7e1020dd457206224 + pristine_git_object: 5520c227abb53afe829024f65cd1d35129922edf docs/models/track-globals.md: id: ea94605fadf4 last_write_checksum: sha1:1886ac8d50d0d16265f9edf99c0fe63a9a18bdac @@ -3769,16 +4025,68 @@ trackedFiles: pristine_git_object: 72c93fc6d6281d74de168030d8a42050570bd09d docs/models/track-response-body1.md: id: d7877cb6c21d - last_write_checksum: sha1:a718470feb9d6cb8bd08b8c07c4add377cc1ceae - pristine_git_object: f007d36a8909c4e858d6f05e0e38acf3184d26a3 + last_write_checksum: sha1:be799b58dcab5d5a345827ac35d292fedd1968c1 + pristine_git_object: 32335fefdfac224f8fff0cf838d626b95088ab2e docs/models/track-response-body2.md: id: 19b3b7964db8 - last_write_checksum: sha1:d63c37bab89f053217e6fa6a67d81d2e3ae4f7d3 - pristine_git_object: 39490470a906394f8a52d7bd4737ece8f76d7bef + last_write_checksum: sha1:d144806b27c1e6c386c9cbda1872f30f8d6c9184 + pristine_git_object: bda31edfca1676d4695b49e6e2c685add41aa7aa docs/models/track-response.md: id: 0d3ebb1bbfdf last_write_checksum: sha1:88d946d90110ade57d3a01098f7427b3fc1b3a6c pristine_git_object: 75774f504a835ea66f48599b3dea9a326456d5e3 + docs/models/track-tokens-deduction1.md: + id: 9635b963a580 + last_write_checksum: sha1:21f5af37185a3f40634e9d6d79e7d39e53d3130e + pristine_git_object: a644f40c821c63003447333dbe602a65e36b5c7f + docs/models/track-tokens-deduction2.md: + id: e09000329b8d + last_write_checksum: sha1:81cbf5ecacab16c0fcf03ac805e08435a6e797da + pristine_git_object: c66b7f5b28698b1766cf114a6248e40e0d87bf5e + docs/models/track-tokens-globals.md: + id: 04fd234ff359 + last_write_checksum: sha1:6158ac0ec82026c82da05280ca93a026238704c8 + pristine_git_object: 895890182dd81f3975bb7d63e1d772393bfe76e0 + docs/models/track-tokens-interval-enum1.md: + id: 4d003b359ddb + last_write_checksum: sha1:fe903bea0e6c91df3a97e5181b49d2554a22d09a + pristine_git_object: 9b157f1baaf03852ff7b3ccab8a6365d3ef65581 + docs/models/track-tokens-interval-enum2.md: + id: cf03f3d39c0b + last_write_checksum: sha1:6fb9a7f5aeff7a6116fd9378cb136813c532ee27 + pristine_git_object: 04ed7c31dca328ea5f408da97f583b7126cee8bd + docs/models/track-tokens-interval-union1.md: + id: 5ce8644bfb2d + last_write_checksum: sha1:b8f333db2fbc35ce0bc8cb72395b7e35d31084e1 + pristine_git_object: e00c6d5a688f2747704325740513bb715dc46a54 + docs/models/track-tokens-interval-union2.md: + id: 99b8b9d56411 + last_write_checksum: sha1:5ce94f8ee579849fe9d9bf17491fdb04f84fc378 + pristine_git_object: 063e5105b47d5fad36fa5993ed247dd06efc391b + docs/models/track-tokens-params.md: + id: 6646069379b6 + last_write_checksum: sha1:be0cdd7f6e6e5685fb9348a3aacef3b9b7074697 + pristine_git_object: 43eede96bcb0396511f8066f9e07eacf9ef637ea + docs/models/track-tokens-reset1.md: + id: 25fe3b59f7e2 + last_write_checksum: sha1:41cf9fe7c9e767c59868746a4a82713efa4309e7 + pristine_git_object: 534c8b8630141635a244a3678579002246a8f2fc + docs/models/track-tokens-reset2.md: + id: 180006a3957b + last_write_checksum: sha1:c6e520f6ab1899d4c0288974a25029d25ce8a294 + pristine_git_object: 9b8796d5b3c67598482b340563f89ab17a47cb5f + docs/models/track-tokens-response-body1.md: + id: 9fae0c572efd + last_write_checksum: sha1:e438340d0514bf5bd3d2014da363bc3b6397b5c4 + pristine_git_object: db1fde2cf8314a9c5288f6fd8815a1ca05016c54 + docs/models/track-tokens-response-body2.md: + id: c3d02fba540d + last_write_checksum: sha1:e6714faef994ab9b3163891058f5d0c06496eb4d + pristine_git_object: 3f3626cbcf2620364fd0383169ebbd5baa591fb4 + docs/models/track-tokens-response.md: + id: 526a21960c83 + last_write_checksum: sha1:72b3b005052e48e0c5336b052685eac9e29208ee + pristine_git_object: 803a1c7fecd3736a661a8ac540acb208dd3f11be docs/models/trials-used.md: id: 983b78eb51b7 last_write_checksum: sha1:0fdc1753311eb276ea3d69f275f9bafaf196e538 @@ -3837,8 +4145,8 @@ trackedFiles: pristine_git_object: 49b1e0d4fa22077061818565a4c51e8516e63087 docs/models/update-customer-feature.md: id: 168e1b65d4aa - last_write_checksum: sha1:138fd6e774ad7c62ea6f214715b6f723ff26860b - pristine_git_object: b5fdba61ccf95299797b627e37cb59b12459cd2e + last_write_checksum: sha1:e0794cad5fa0c13085743b4233b098d143e06deb + pristine_git_object: e3fb56709b8376d6a5efb551f9fef25409022e6d docs/models/update-customer-flags.md: id: b8425c14de67 last_write_checksum: sha1:f4a4276653dd7cdc854f2fda1498237887270fba @@ -3847,10 +4155,10 @@ trackedFiles: id: 143999995aa3 last_write_checksum: sha1:68ee60cc1c95d8bae04f3df054e154eba1adad69 pristine_git_object: 2c4647647c8e127048ff63b32b2525ad7bdeabbd - docs/models/update-customer-interval-request.md: - id: c82b4d368e35 - last_write_checksum: sha1:33d7010e9cf0c7d3214a39e7d79291829f2e9d59 - pristine_git_object: e44fe97352c3cda9c0b20cddbca6c5b6139d6a1b + docs/models/update-customer-interval-request-body.md: + id: 9619e325c545 + last_write_checksum: sha1:37b216ee7d51547505232d84620b47d1b2acd97f + pristine_git_object: c512831ab9ee246fb0de9388fd5b309003c316a2 docs/models/update-customer-interval-response1.md: id: 2f370ecf51f5 last_write_checksum: sha1:d7b951441e5a028863a3f2cb61b4a02ed82d5cfe @@ -3859,6 +4167,10 @@ trackedFiles: id: 970344c88ece last_write_checksum: sha1:978e14a34eb16e81e957605413d84fcb63350946 pristine_git_object: a9f548dd0ccb3b32126369fe435945d01f15d8a4 + docs/models/update-customer-model-markups.md: + id: ab371c719b8c + last_write_checksum: sha1:8fdb60204592a97e91513becfab9ad4b92f5c154 + pristine_git_object: 9b36a878dee1126b46e5500b9da8dfd532c46f02 docs/models/update-customer-overage-allowed-request.md: id: 3121f2b883b8 last_write_checksum: sha1:c72330c9401006033b12aa7512d9bd2011eaa726 @@ -3875,10 +4187,14 @@ trackedFiles: id: 24f0b4264c46 last_write_checksum: sha1:47698ead6f5c92dd77b2c1b0f8e1547b9996547b pristine_git_object: 1cf42edc2b3f1b86267211a31be9d645e2f2dca1 + docs/models/update-customer-provider-markups.md: + id: fee0e12dfc4c + last_write_checksum: sha1:88d65fffbc38657c9de3a5c07e7bb6672e89ef54 + pristine_git_object: 3008ca0c4d63b0bb7c26d7237963ff79e144adf4 docs/models/update-customer-purchase-limit-request.md: id: ae71fddbf2a5 - last_write_checksum: sha1:4f54b8bd7d91783bc67dbe9baf20905d5a6aded8 - pristine_git_object: c575459ca8fddccbfc4b5ae496cd72773b1aca81 + last_write_checksum: sha1:0d6bb0e66b7df5ccbfc2b29dcb4c353bc5ece022 + pristine_git_object: 3848fdfb1942b0045842503670f6d39e13551df0 docs/models/update-customer-purchase-limit-response1.md: id: 4706d17e1852 last_write_checksum: sha1:6c9ed4ff9451d1215de322c61cd9c0a82732da15 @@ -3941,8 +4257,8 @@ trackedFiles: pristine_git_object: ad2d2120563999f327f416949affe11a6213b0e2 docs/models/update-customer-type.md: id: 875a45ee72f2 - last_write_checksum: sha1:044907040da1e827528a5a25f99df920f9ffcfbc - pristine_git_object: 83d707b139180304ae75f646872142d39330a358 + last_write_checksum: sha1:9ea193975683edda9b0429bd2a9bb6ee8cd4df5e + pristine_git_object: ef6b825a827e784ac7b011f7608d5f98c905a212 docs/models/update-customer-usage-alert-request-body.md: id: c4f5132a2774 last_write_checksum: sha1:8d8b346e54e761c10031747deda4c51b1e2dd933 @@ -3977,8 +4293,8 @@ trackedFiles: pristine_git_object: f8515ff3068a6db2e14fdf925285e6cd588bd3f5 docs/models/update-entity-feature.md: id: cdf0e1868d89 - last_write_checksum: sha1:4ac2661631f51e41dc56ead085ba1c4f3c18a416 - pristine_git_object: a610539dc606f7f57451cd1f6093e47b0df6331b + last_write_checksum: sha1:f7067d758589296b56218c94934a2b760112bd29 + pristine_git_object: 608dc222c650681b713a79c2bfa6da8f74c36508 docs/models/update-entity-flags.md: id: 99f9a717cd69 last_write_checksum: sha1:39b4b79ef4e6a851c51e8fbffdba6ae427625d88 @@ -3991,6 +4307,10 @@ trackedFiles: id: 4ee8a5bfaa24 last_write_checksum: sha1:0b116cd19da15bae045fe458760acdd21df51700 pristine_git_object: 9f43c40ca1bb11a42efd7037d6757f6da3ba8f13 + docs/models/update-entity-model-markups.md: + id: 22e59255d126 + last_write_checksum: sha1:9179d96fcf2b38310fe0942b522d9210ee55cd70 + pristine_git_object: 0cddcd70644d6f5ef51f8b2a184bf5b390228ac3 docs/models/update-entity-overage-allowed-request.md: id: 6400ec1687fe last_write_checksum: sha1:7c761fd256770844b4692b170666ffd7c2fb6584 @@ -4007,6 +4327,10 @@ trackedFiles: id: 4348a3cfe101 last_write_checksum: sha1:151501cfd35be4e0b1071f3a01e1f35e66fbbfda pristine_git_object: 243e8dddc125708fc53ed0ade38080b365a11381 + docs/models/update-entity-provider-markups.md: + id: fd323f54c3e4 + last_write_checksum: sha1:30546b742e82ffe8a5bc6f309e337d5ab423a766 + pristine_git_object: 7737f21c5da357c7ea1d7c54354cfe52865ee89d docs/models/update-entity-purchase-scope.md: id: 6f100109f8de last_write_checksum: sha1:d7185182020c302c0d37cddb07708e7d5c6d73c9 @@ -4049,8 +4373,8 @@ trackedFiles: pristine_git_object: efdce5ecb4238e6a258fa86bb7d014c2ce3d573a docs/models/update-entity-type.md: id: 394dfb877cb7 - last_write_checksum: sha1:6a66ba39192c4124f1d88a4002968a1375057eff - pristine_git_object: d978e0a8bc80c75a43521c2d1ce78d0a11132270 + last_write_checksum: sha1:7a75d7e02b2a4767a2abe986ee015c2ddb4e37ce + pristine_git_object: 053da1134577d68dc6623c03fcc4b8145e85ac8c docs/models/update-entity-usage-alert-request-body.md: id: 327b3aeb824e last_write_checksum: sha1:fc3f241aabbde6436e9ab52c1ca4e51681af4c24 @@ -4059,18 +4383,18 @@ trackedFiles: id: f176e3c4b1f2 last_write_checksum: sha1:c2ecf081e8085828190cc25b2f4e1d974273f640 pristine_git_object: 8b499acbce0ffc412737d0aeac3555a74798928a - docs/models/update-feature-credit-schema-request.md: - id: a82e108a3756 - last_write_checksum: sha1:9aeca0b0271a2657e571cda1a2182ba633d8efff - pristine_git_object: 3f6ce15418f9fea8b3ccfcc4a46696ecabe08156 + docs/models/update-feature-credit-schema-request-body.md: + id: 270a9793f6c0 + last_write_checksum: sha1:98505e08468417e1e9843a7ebd499a08cc0379f4 + pristine_git_object: bc352e9b6da80b9f8068aba3a2cdfc72dab19498 docs/models/update-feature-credit-schema-response.md: id: 82fb41c692c9 last_write_checksum: sha1:3eb2e3a87d3a64d7eb8c3692340c0647616fc3f0 pristine_git_object: 67ab2b75d49f2a71c0e09963adf64f06d1e31539 - docs/models/update-feature-display-request.md: - id: 453cda9a2dbe - last_write_checksum: sha1:6cdefcae7159d35fb7106f095bc3bf5a72558c41 - pristine_git_object: 0d19f58b6dd9e186a21dc81383e5267dc5753000 + docs/models/update-feature-display-request-body.md: + id: f38d416ef59f + last_write_checksum: sha1:345877c0a3b7a8d933f6793c2b51f8aba60178d5 + pristine_git_object: a02105d586bd360116015f9dda1aa82a4fb4b880 docs/models/update-feature-display-response.md: id: ece0e8b1337f last_write_checksum: sha1:d0ef83fbaea1a146de78843e5476f3a6c10082f0 @@ -4079,34 +4403,50 @@ trackedFiles: id: 171fc3b6cf8e last_write_checksum: sha1:eac8f23a6e8c6c84f30e0c1c84583b1f486003be pristine_git_object: ab335d60e637d17dd6421cac01f05d608176d9b6 + docs/models/update-feature-model-markups-request.md: + id: a9edecb5fadd + last_write_checksum: sha1:3a5ab53ff2dcb2f39a1ea9732789b6545d05cb39 + pristine_git_object: 56f6a10dfdc12f898fe14627375c4583c9519c38 + docs/models/update-feature-model-markups-response.md: + id: 3c0f72096350 + last_write_checksum: sha1:55161981b417940edc4c0c8ab614ed6eabfeb27f + pristine_git_object: 7e2eaa54f541e29ea0762f4006df590dd8be244a docs/models/update-feature-params.md: id: cc897af6b522 - last_write_checksum: sha1:fc67703f1cf1606df717cde5ac0a2b06a0ae39e1 - pristine_git_object: 06a36effe4853bf70a15cad67b62959890d7a918 + last_write_checksum: sha1:1fac164be93b1360f595dd26075a1ee45dbf34c4 + pristine_git_object: aa052641a237f784bbe53f5c920333618be772ce + docs/models/update-feature-provider-markups-request.md: + id: bf8cf323a058 + last_write_checksum: sha1:c4186f8d3c940ae5c47cbfe5e4f48fa96be88321 + pristine_git_object: bc8df80e4df82387fee61463232cad5555fa85e6 + docs/models/update-feature-provider-markups-response.md: + id: 08484a50b99b + last_write_checksum: sha1:00dd38bf861dc764bb169ddc8536c7c61d329df3 + pristine_git_object: 970ecb6fe4d963d6dffd822aae2f2514d109b672 docs/models/update-feature-response.md: id: bbe6d9dc68d6 - last_write_checksum: sha1:1453bda7368d4ba0ca284fbcadfce5f097a96d6a - pristine_git_object: fbb3e03193e3c9b1cc88d801f083f82f8c0ad616 - docs/models/update-feature-type-request.md: - id: 7bc5df8b7a17 - last_write_checksum: sha1:0f933be4da1638b71db24afec76dcdbf2ccc2d2a - pristine_git_object: 011cd5876e919cdf84167f388a8e576b2ff96e50 + last_write_checksum: sha1:62726c378817470caa5618cb2beee2177ea64ddb + pristine_git_object: 9087eb3252efd3ea7cdfda7adbdd5b92480dca35 + docs/models/update-feature-type-request-body.md: + id: bc72d6173ab0 + last_write_checksum: sha1:3a49e384471a335f58fdd8bb0d8565efbf77487b + pristine_git_object: 9d87efc863f9b1f08f00de8cd4f8ef2bd3fcd1f1 docs/models/update-feature-type-response.md: id: 55ef8f61ed28 - last_write_checksum: sha1:36bc60e36ec75ab72b26645751fe8e4a02437053 - pristine_git_object: 8feb59b6f2386611f2db04d66bf8d95202e1d9ec + last_write_checksum: sha1:faf37d8d85056c22c1e7718c15ebce9706291ff5 + pristine_git_object: 46197bb53415db585d6e501b766ff57c2affbf9f docs/models/update-plan-attach-action.md: id: 3f596f5740b1 last_write_checksum: sha1:02f0e4cb31d8b2cd6ba5d6a38b16d10dcada44fb pristine_git_object: e67655ce96d6c0b48a0a6930027a0394b816136d docs/models/update-plan-base-price.md: id: 7f764af15f7d - last_write_checksum: sha1:342436cc671fbfa0fa02779f5635f2ae28804626 - pristine_git_object: 7b3f6d416f8f2846eb79868d1774ca93487c5756 - docs/models/update-plan-billing-method-request.md: - id: af48a5d0fc97 - last_write_checksum: sha1:824ca7edd0df4cee44e27224395b504f970e0b1f - pristine_git_object: 3fcfc1ffd0e051ca7d6267202af8ac6756a48f0e + last_write_checksum: sha1:0586f525ec869d383ec7755d4446d023bffaec41 + pristine_git_object: 0d9ab5140c55d479c71e3086784d518abc297752 + docs/models/update-plan-billing-method-request-body.md: + id: 4632c80df04a + last_write_checksum: sha1:2c0e1bcad17827dae43d039adcde3b0015401080 + pristine_git_object: ecbe4500e128683cd5ce2c4d4753ded91e036dd6 docs/models/update-plan-billing-method-response.md: id: ddca1b594754 last_write_checksum: sha1:684b8ac067ff95bd19c8252fed27767389012afc @@ -4139,10 +4479,10 @@ trackedFiles: id: 1ec7258b0baa last_write_checksum: sha1:d4594b9ca68ab42976728a90051e63158147be0b pristine_git_object: 1a9287acd4e08b4512cfbcdb04682ba80b280089 - docs/models/update-plan-expiry-duration-type-request.md: - id: 9deda139c872 - last_write_checksum: sha1:ed51270204f6b3b01d4d4f4fc2069debfdddec88 - pristine_git_object: 89747b93d89370fa39a0b3a802c255401ab17567 + docs/models/update-plan-expiry-duration-type-request-body.md: + id: 0a9fe99991d1 + last_write_checksum: sha1:d1ce6e23149b5b9f794a315379d7b279b6a269a6 + pristine_git_object: be439ae0ec03b7dbba481c8d39fc5417606643c3 docs/models/update-plan-expiry-duration-type-response.md: id: 4f1456745f6b last_write_checksum: sha1:804c08642b3c40af78c8f534239f6ec353381ad0 @@ -4153,8 +4493,8 @@ trackedFiles: pristine_git_object: 12a3fcf7180f9f90ccc28d7312b192fdc7fa18a2 docs/models/update-plan-feature.md: id: d12b26ef19a6 - last_write_checksum: sha1:37399e6b4f6b83cbdafac8ae03d63c079eefd5ea - pristine_git_object: 842a819efcccefb73ebfd1e41308442d0c45664f + last_write_checksum: sha1:20e3ae5ccbd01b241759eb728e0eb70d6b09bf0d + pristine_git_object: 5942170a7fb7be895d157557584c724ecb21befd docs/models/update-plan-free-trial-params.md: id: b9e9cd0a21a3 last_write_checksum: sha1:a9cd22022581c0b49fd26123458624a468f26f1c @@ -4171,14 +4511,14 @@ trackedFiles: id: 232cec417ab8 last_write_checksum: sha1:19f639764a0cc88104d8940cd8cdc679fe192f3b pristine_git_object: 11bcb3d4216ae8298ca8d423394eb4b9a86b4c0b - docs/models/update-plan-item-price-interval-request.md: - id: dc5f73e10be9 - last_write_checksum: sha1:ae35c3a73408b753de158ac14acb55f7b37f5b74 - pristine_git_object: 47c968da029557c2c8c0e37a6492c281aae4fefd + docs/models/update-plan-item-price-interval-request-body.md: + id: 2d027b9e901b + last_write_checksum: sha1:4e8002f8629fcbd68c45543f882c63373bed1bc6 + pristine_git_object: 6926bed8bcfced43d70127b3bf0538418b9211c7 docs/models/update-plan-item-price-response.md: id: ae8fef4236c2 - last_write_checksum: sha1:f1299b3367da706d068a76d9de0044b332b56b63 - pristine_git_object: b9e9e2bc78bc57361b780fdbedb5b2673640f523 + last_write_checksum: sha1:434af88a24564075d1cce3e7f82dfe57fb8e924c + pristine_git_object: 4e06391978f1e9f11290920eebc1edb6a65aed9a docs/models/update-plan-item.md: id: d9917caea5e5 last_write_checksum: sha1:b3f8dba86c631ca361652f7b474e4dc80bdaf154 @@ -4201,20 +4541,20 @@ trackedFiles: pristine_git_object: 585c71d0660618338988c819b2c60b5ee4ea29c9 docs/models/update-plan-params.md: id: baf87a97d876 - last_write_checksum: sha1:1ccdf57cc7b2cfe5d2ccc2e1817535d9f2cd7007 - pristine_git_object: 906a6141fd614d723ed9c49668993cc677147fab + last_write_checksum: sha1:6b1b04cfd836c4f061c7b6a905a8e98662d6dd7a + pristine_git_object: edc2409faf4248b6be0cb848090e5f9d44900d52 docs/models/update-plan-plan-item.md: id: 681d03177a17 - last_write_checksum: sha1:9cec44182899deefeaf66dbb255ab7fe80da50b1 - pristine_git_object: 96a71cd4096f4df8068ec0ba3c33b7a1c5cf7f4d + last_write_checksum: sha1:e3ef7e59837dbda4b5cce189b58bf93b2206fa04 + pristine_git_object: 3efb4358325106413ea3ae5f6e6d7c8d9e9eb6b6 docs/models/update-plan-price-display.md: id: 76872726ec8f last_write_checksum: sha1:79202a6ddbaa7c0907cba3ecba2c906d14351ebe pristine_git_object: 13b237d44534236344dba4fa2eebf79fdd1e623d - docs/models/update-plan-price-interval-request.md: - id: 4ed26e8ca8de - last_write_checksum: sha1:d2339141b19e242915a2ca1cdff0a390a726eb31 - pristine_git_object: 308fdd7c25468a6dd81b07ed46391d1a9bdc0113 + docs/models/update-plan-price-interval-request-body.md: + id: dacd139c63d8 + last_write_checksum: sha1:c2eb57d4b8977ec7a0f1825a4b19bd8c6e11b7ca + pristine_git_object: 03b1deaf659d06a40edbb5cbd07cb85b667b311c docs/models/update-plan-price-interval-response.md: id: 895a33399c7b last_write_checksum: sha1:cc3a352b3f4c6a155eab5c20297a4d9f423d0d43 @@ -4223,10 +4563,10 @@ trackedFiles: id: e909a35141c8 last_write_checksum: sha1:ef54f93e6dd1c247b2fd91615e01655f5d090c8d pristine_git_object: 82e7a008247cf0745ee12616656dc0fc885b482c - docs/models/update-plan-price-request.md: - id: 4602b3ebb686 - last_write_checksum: sha1:4a7faf35e7d8382ee50e8cde87cf15cfc034fe5c - pristine_git_object: 5dedd917f560a06f35f45e3e145a59e258e65bda + docs/models/update-plan-price-request-body.md: + id: 62cc65906bc7 + last_write_checksum: sha1:7aa6f8ba22b6e00212587922a73c5f5e44d86238 + pristine_git_object: e6aebbd9767e9ed65516f656b0dcf044d5dccff6 docs/models/update-plan-price-response.md: id: 60c497fb4b67 last_write_checksum: sha1:15832c93a4476d978d026c896e7d18cb8a418540 @@ -4235,18 +4575,18 @@ trackedFiles: id: 99b900631e5e last_write_checksum: sha1:c1a67717271adb3a438aee61c9f69905ae0c386b pristine_git_object: cffd373fd23ac17c837c3c68f6e152a0fae5982f - docs/models/update-plan-reset-interval-request.md: - id: 1478e7edd296 - last_write_checksum: sha1:b5564bcc3c60065f8064a3a648df8959fbefe9d2 - pristine_git_object: d30dc71cbb6362b2817893a7b9d85f600b3fe704 + docs/models/update-plan-reset-interval-request-body.md: + id: 4f9da599f936 + last_write_checksum: sha1:03be5f9fff4fc9d70025797022a9ba039866ba15 + pristine_git_object: 460775b4796fd8990d93d6e1e599aeb33cc5170a docs/models/update-plan-reset-interval-response.md: id: e04031025a69 last_write_checksum: sha1:7cf89cfb935aa10335fd3e731b1510b0cb7b124a pristine_git_object: 39bb94ebcc183905b3d84961f59a6ffe16ef8221 - docs/models/update-plan-reset-request.md: - id: cc5ffaf44e69 - last_write_checksum: sha1:7b5494303638691323b06a24345154f1922c09ae - pristine_git_object: 6ee22252809b0da65c14987393062e398e2c44fd + docs/models/update-plan-reset-request-body.md: + id: a518405ddeb0 + last_write_checksum: sha1:48196de458856709770d675bf29b3d94f91abc32 + pristine_git_object: 89d1204f66556e36a98a0aa30a86b29ac99b2e41 docs/models/update-plan-reset-response.md: id: f8864349bc3e last_write_checksum: sha1:80593a5152adeecc45e139e0ff2daefca75b5b7c @@ -4255,10 +4595,10 @@ trackedFiles: id: 438137d6d905 last_write_checksum: sha1:e44c03a173c911d8c8e9c392048270ed19229f1a pristine_git_object: 0fa67fd6b6f5b7c2def19cda7c60d1942ea2a7bc - docs/models/update-plan-rollover-request.md: - id: 70f500c0dfa8 - last_write_checksum: sha1:4830be56801429a844896eeb64eaee855b1011d8 - pristine_git_object: 91dd5194318ee6ca3b753a12848aedded67b51c2 + docs/models/update-plan-rollover-request-body.md: + id: 8dfaf3275a43 + last_write_checksum: sha1:d05eb76cdfebf49b824c2f46e577b054a0649290 + pristine_git_object: 93478fc5803a9bb2515bfca6adf5083c72bf5669 docs/models/update-plan-rollover-response.md: id: d6683f4aae90 last_write_checksum: sha1:23f0de66bf12536012d40e1dba44ef2a0af40aed @@ -4267,26 +4607,34 @@ trackedFiles: id: f2d8e8969aac last_write_checksum: sha1:0c7f9b87be7ec7992c7a7cfae8889af100c4b5b2 pristine_git_object: d8f5473b55509af4d6b3ca9a5100516c32944f61 - docs/models/update-plan-tier-behavior-request.md: - id: 8ce61fb8637e - last_write_checksum: sha1:b9e837d1016b53e5ba973bdb3aec0f63a3cd7bc0 - pristine_git_object: b027e08b063fed9b87885cae46a9fa282124596c + docs/models/update-plan-tier-behavior-request-body.md: + id: 03746f8b367a + last_write_checksum: sha1:49768fbb9ff009adca794c8b29fd4e627c252f47 + pristine_git_object: 5a275e6169620103498eb13f0b68723bb558c333 docs/models/update-plan-tier-behavior-response.md: id: 4da31d438a94 last_write_checksum: sha1:319738e80d8e1648e746fd5b41fde23412eb6a1e pristine_git_object: 1c7755baaca987e11d366427182be05949c8fbc7 - docs/models/update-plan-tier.md: - id: 0cac01f522a2 - last_write_checksum: sha1:65a9e826becab3da5c24fef7fbf915992df4dd01 - pristine_git_object: 250ad01996134d440c8fc6a6e760c9b293c66234 - docs/models/update-plan-to.md: - id: e1b3b8f938bf - last_write_checksum: sha1:9fc8a805479a1e1aa20d013bffe0fb4d4bbee5f6 - pristine_git_object: af6af1c81c983d8a50d21b44329a3aa0bd24ebed + docs/models/update-plan-tier-request-body.md: + id: 1e664a4d8efb + last_write_checksum: sha1:b1690bc56dca3fc6ec54497dbae54577a56b2f75 + pristine_git_object: ebd0954d6285234ac315e029d5ec8f5f7f9d5545 + docs/models/update-plan-tier-response.md: + id: 47780329e7b3 + last_write_checksum: sha1:a3b22f131b2ed17508e24d5f2902210f49fa089b + pristine_git_object: 323ac914724a11efd94e36677ce6ad6194814d54 + docs/models/update-plan-to-request-body.md: + id: 03d5fdf6d6f4 + last_write_checksum: sha1:4d310152628d9b6b2f7ecd3f13044085de3fbdea + pristine_git_object: 0af7ba7f5044a97d3b6820a35809807c44029cc7 + docs/models/update-plan-to-response.md: + id: d59b72ee8c42 + last_write_checksum: sha1:51dc9fc0e64eade8ed9c9082b9b1916dae72f750 + pristine_git_object: 95d1c8e8cb7c7ade9ce1970e92c1cc899e1e3df4 docs/models/update-plan-type.md: id: 5cfd33c31fa4 - last_write_checksum: sha1:e6925622d28aadf7dda81743c075f806a1889b3e - pristine_git_object: ef8461bb5f5b6dde98c8f325bb176effbe346b88 + last_write_checksum: sha1:e2f01083a8a7f574e337269c762f1a9da032ed64 + pristine_git_object: 584b43f2e98cf7f241d98eecd320e815144ed789 docs/models/update-subscription-params.md: id: 2f1abbd42a8a last_write_checksum: sha1:5c1757a7109e6925391b398bb87223028bd4492d @@ -4305,16 +4653,16 @@ trackedFiles: pristine_git_object: a844f92b2baa6185834718690d34643de01d62b2 docs/sdks/autumn/README.md: id: d27c9292a1a3 - last_write_checksum: sha1:c615e04981de1c8f017bccc3c48a3549fdacf9f1 - pristine_git_object: ff3a9aaa764436d0f5fe83a700248d88b3895804 + last_write_checksum: sha1:ed0ea352ba9be72cc6ef053de2e22dc1dbc15f61 + pristine_git_object: 42c2fa4a5cdac5a7d6bc7417edb8af6086cd0261 docs/sdks/balances/README.md: id: 6ca85866f00d last_write_checksum: sha1:bd1e814fa0fa46eb24beefce2e585d9c8e4cb14a pristine_git_object: 0ebe5146cd24c153cb9b7655e4f502c09f7d4abd docs/sdks/billing/README.md: id: dc915331dd9d - last_write_checksum: sha1:a29da461dcad1fea4fca629be432ee960ffa6cbf - pristine_git_object: c07f3e93c879598d44b58834323533c6f1897aef + last_write_checksum: sha1:4915a707545ceac422584bebfe3cfbe835f3ffd6 + pristine_git_object: 5a769d7fb85b6838f06600479aa35c04a89fdd3a docs/sdks/customers/README.md: id: 9332759cffc2 last_write_checksum: sha1:74cd5f6cf800e1d86b2c332fed3c3cd53f3eeb6b @@ -4329,8 +4677,8 @@ trackedFiles: pristine_git_object: 1cc83472fe4220acda4c28e42b5425ed182b93d5 docs/sdks/features/README.md: id: e885cfb7247b - last_write_checksum: sha1:88c210eae53f4461250167ffb7f9d393449e40e3 - pristine_git_object: f8cb7e169f99bc7aaa0b6c3f50f1b2ff19b8d1eb + last_write_checksum: sha1:7a8aa9c003f1e66331f8de264ca387f1c6dcf938 + pristine_git_object: ac31687aa9e81d6776ab4933d0ad07afeff8a6d0 docs/sdks/plans/README.md: id: 2d8c741fff57 last_write_checksum: sha1:57e57bb309355ca9bd404327d90d5ec43e26c6da @@ -4405,8 +4753,8 @@ trackedFiles: pristine_git_object: d1d2c39eb61de5da6dc66da31605995ee35edd8b src/funcs/billing-create-schedule.ts: id: fd662bfcdc10 - last_write_checksum: sha1:a11b4f10543ae07e6dc84797e6ce0789ec0351ae - pristine_git_object: cb81459b84bdf23fd77e3fdb6b499e8258c4929c + last_write_checksum: sha1:35656f3afb666a61aadfa02469cfb39de2aac9cc + pristine_git_object: 34a3180f80af0c381272ff0ffebcb7158025e08a src/funcs/billing-multi-attach.ts: id: 67491e2d8249 last_write_checksum: sha1:00ba80c1f98e7a8be29db0cf5a6957433f687861 @@ -4489,8 +4837,8 @@ trackedFiles: pristine_git_object: 0fa2cf885374afba83ffefe395b0c88daf57a948 src/funcs/features-create.ts: id: 084c8347da5f - last_write_checksum: sha1:f3e2bfa9124c1e5fddc44f13a1924816055005dd - pristine_git_object: fde611632181a32ba9aa5d2b819be049c4c842c6 + last_write_checksum: sha1:edfa08964810938b46c53b7e076c1926b9e28c91 + pristine_git_object: 494f44cbf336e153303b40d82118f565e20c4417 src/funcs/features-delete.ts: id: b3fe7707ab09 last_write_checksum: sha1:6908da99bfc8f9f8f79843b727ad93eaa2f6552b @@ -4505,8 +4853,8 @@ trackedFiles: pristine_git_object: 08841097f06a6a3a87073ee4ebcfecff13439a28 src/funcs/features-update.ts: id: d2de3d7decac - last_write_checksum: sha1:6855ef26ce42a8b903c081802de4ecb37d49cae0 - pristine_git_object: c5c55893e5eaf896e7c2dfb825dc9be83dcdb8bb + last_write_checksum: sha1:ab1ff29e5b73356e4a0fee309aeb199570353edf + pristine_git_object: 0e8b430aae0d2ff7f1f12c3b0d08b86e70dc814a src/funcs/plans-create.ts: id: d67d1d814264 last_write_checksum: sha1:d2d5cc23d3c896755bd106649cff13060d0e76d1 @@ -4551,6 +4899,10 @@ trackedFiles: id: 8f4dac885341 last_write_checksum: sha1:ed813dcce1c2ffddcf0bf86793852b34aba3a927 pristine_git_object: 1555c8a918edb10c18af2a74bddc1e1e96711bb0 + src/funcs/track-tokens.ts: + id: f8cc25a4df86 + last_write_checksum: sha1:46f61e203d529109e05402d58bb75589e1e438b9 + pristine_git_object: e838c44ac3f40e0b170aba539c3bb6b2340f79a8 src/funcs/track.ts: id: eb7e0b123329 last_write_checksum: sha1:0495a4e4969e206b0d58747b9dbf3acc6beafc07 @@ -4641,8 +4993,8 @@ trackedFiles: pristine_git_object: 56dab308d7f8e1694b20a8e0509ecfa0b4149743 src/models/attach-op.ts: id: 83ed65c26ab4 - last_write_checksum: sha1:283141753e7fb5a3be8596ba32e402bde38c96ec - pristine_git_object: 8594c479a2e40f632a9add5894e921e63901b0c0 + last_write_checksum: sha1:eb6c692d5915d1671f1c3d6c3ea6d0e7e2e56187 + pristine_git_object: 13f0de38fff0370b2a430966fee486b63704c5cc src/models/autumn-default-error.ts: id: 2528aa7886eb last_write_checksum: sha1:4cce18f91be3262ada7d11dcd6326544e2341b58 @@ -4653,44 +5005,44 @@ trackedFiles: pristine_git_object: 7cfcf2c90f1cfaa152ee2f4033187aa20b1c696b src/models/balance.ts: id: d7bbe0a7b446 - last_write_checksum: sha1:1283f88044007ac767ab7d1eebbf3480575113e8 - pristine_git_object: 4b4ccd5b5a62412ae6d02baa88fecb8fbb89fe12 + last_write_checksum: sha1:c6f81072d2b7135bbe326741924008fce4bde544 + pristine_git_object: 942beabe25a42adc72a2236d90515c17708d6dee src/models/batch-track-op.ts: id: 4d4addc42536 last_write_checksum: sha1:65242a83ff54ee30784cf3b590c3adcf209c7a7b pristine_git_object: e3fa7ed778c7829d115c8a51f03df4618c9bf8f5 src/models/billing-update-op.ts: id: e7371769c7ca - last_write_checksum: sha1:191dd8f7961225374c4cbbd4628707f059a4ae36 - pristine_git_object: 53ddec09ce985548f2425cd7298b7f46a5d33249 + last_write_checksum: sha1:68653742a9321e77879c6d7b7da50b496a29bc62 + pristine_git_object: 1c72c100a0b917a856d4333b8e18cfbf5ffdded7 src/models/check-op.ts: id: 42085bda016a - last_write_checksum: sha1:89080a0266713ae7e46a401322f538874539e7e8 - pristine_git_object: b6c962e140cdc5f81795a593008095b4a2e3d002 + last_write_checksum: sha1:df349882fae31170bebdfe63db42f3a04e6d8ad3 + pristine_git_object: ec02ef12b6c56007b59c3dd5feda9298030f3b21 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:051d8b268ec0d4a99675ba43802b52af553e4802 - pristine_git_object: 712cf74bd7ae783d74d149866970054b6674803a + last_write_checksum: sha1:18b8f93021a42a1ea5789e70cc440ef2333775f0 + pristine_git_object: 4b6deea02b0240b181e990e49ea0f10664d4ecb0 src/models/create-feature-op.ts: id: 06f0161d677b - last_write_checksum: sha1:2343ab517f362b3eaaa65e9039cec647ac497111 - pristine_git_object: 74a7f92cc3812461d09b81b1a85c7275026f2162 + last_write_checksum: sha1:292bcc2cf750dfbbf50aba9e3d0543cb5aa31973 + pristine_git_object: cc88593cb431fd33f22679a5eabae1eb9705c384 src/models/create-plan-op.ts: id: e094d152f358 - last_write_checksum: sha1:7f626da9d0fa036fb9ca97492b503b1dacef59a1 - pristine_git_object: 55813fba86358f2642469b14c10c7968b7897d78 + last_write_checksum: sha1:186a53fa60f64071a408fb41325225d90d6c3300 + pristine_git_object: 67bb2006c7fc532ae35e35a368029f73af815184 src/models/create-referral-code-op.ts: id: 745cd70e7a69 last_write_checksum: sha1:01ce64d29c3bd84e0c9bf1e6a979e7e493f10d67 pristine_git_object: d979198ac227f8e5731e5ca5e2e34b55d88da348 src/models/create-schedule-op.ts: id: 68442c0abd75 - last_write_checksum: sha1:e1f55a32e6b44b7a49fce2b3597b88135a091b38 - pristine_git_object: 23921226d0eb02f47e774f680c89a94501184c78 + last_write_checksum: sha1:a292dbc1c9bccd0bfe1f97164218dae3b86b3c74 + pristine_git_object: 62fd84ac74d10ee85d5ea481cb61664a07a34adf src/models/customer-data.ts: id: 04dac7ee392e last_write_checksum: sha1:be3567d013982afb9add249f49c8743a28756c5c @@ -4701,8 +5053,8 @@ trackedFiles: pristine_git_object: be207b832d8666ef05a4085ca1cac7634c7ef43d src/models/customer.ts: id: 20be78c552a4 - last_write_checksum: sha1:23e6124d81ad4231fc133746d23e7b1fbca1cb05 - pristine_git_object: 80b661b2e4e30e4415fbc09250c6a5d537e6ec0c + last_write_checksum: sha1:df1b749839eba0595713eefbfd863e654182286e + pristine_git_object: bdadd80af85278bb85ea09997d96663116e711e5 src/models/delete-balance-op.ts: id: ea84d6bda9c3 last_write_checksum: sha1:0b32332d27f57283623ad915e603de4529f49c02 @@ -4729,24 +5081,24 @@ trackedFiles: pristine_git_object: 893139dc0b5a70fb74322e31c48a75b1fd095a77 src/models/get-customer-op.ts: id: fe8daa5a7d99 - last_write_checksum: sha1:668b1e89e7b672c5f138914d211dbee446e48085 - pristine_git_object: 35f1dee9139f84add44aae4ee85c869c319381ca + last_write_checksum: sha1:7acab0a4a78e4c78993b584ab924c6ba68a9002d + pristine_git_object: 4587b12a5e1df2c475e9d57089df62a4983c4403 src/models/get-entity-op.ts: id: 7932a3cea5c1 - last_write_checksum: sha1:3c10cfac7edd8c310e4982d27c13e641a93d1e4b - pristine_git_object: 38052f3bf5ed8e67e3c231bad2cc5e6c888f7f56 + last_write_checksum: sha1:ade4b33108d9e9f8288290fa82cf04cac724abaa + pristine_git_object: 48ea79f0aa18df0df360c38229d863baa602ea16 src/models/get-feature-op.ts: id: a820efa3e08d - last_write_checksum: sha1:e42d99dba37a2380e1e9ed4afe9b69460598f578 - pristine_git_object: 2dab76561c71950e9622b079172e1d0dc339b081 + last_write_checksum: sha1:acea597a934387aabf8e9112e0fb5f53060b9fd4 + pristine_git_object: 0097008c9b033d8069530b1b473a5a6f890b002e src/models/get-or-create-customer-op.ts: id: 46f8f65a57f2 last_write_checksum: sha1:9d78d6d79c7af90feff7b8e9464fa6c7d2764c42 pristine_git_object: 655ee3ab45228b557622892c47c772b827b85c64 src/models/get-plan-op.ts: id: 91c8f8dda7c8 - last_write_checksum: sha1:bce81a8cef1f6bf579185174603c9c414a26b258 - pristine_git_object: 43ea5f27ac16bea209d71a03ad517a798fd05c5e + last_write_checksum: sha1:ef236307f6f56d7c8851569d37dc91bf724b9cc6 + pristine_git_object: a7e532169f00e41c82fcfd1e3da0849056a581c3 src/models/get-revenue-cat-keys-op.ts: id: 2d8e9e87f071 last_write_checksum: sha1:e1c1f2eaf75a3db81b4800c63cbe2ea6e5b1fa56 @@ -4757,56 +5109,56 @@ trackedFiles: pristine_git_object: b34f612124c797c2a1106b9735708f679a90b74f src/models/index.ts: id: f93644b0f37e - last_write_checksum: sha1:2c57b1fdb9734c9ccb0f127da60d510b6d4a164a - pristine_git_object: 9c3a0afcd755cc0dcc073565280651597367188f + last_write_checksum: sha1:ca80cfc8f27c22ebbd158841ddd0734a5202ab5a + pristine_git_object: 00397486e51aeb6a1dd6d003ed898d75722f9f82 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:2b1ab0d5e34f41d91a1d92ac4abd87ec54596bd1 - pristine_git_object: 7f4377171a1cad0e1e90e46770381c5bec2afc56 + last_write_checksum: sha1:71a4002645d598e88e7ce6c533d6881c3773c49f + pristine_git_object: 129da03ae76a8a97140f511b186d0b7edb1b7066 src/models/list-entities-op.ts: id: 4cbb69f4a0cd - last_write_checksum: sha1:b7c8086e33de50fcacf18501237486e12a8158a1 - pristine_git_object: eedb101e43e1f9c51e9b7c01efd66e62a4da9e80 + last_write_checksum: sha1:673a515cdcf9a393e4de36f655ad4256f00a7275 + pristine_git_object: 339f8fb9ff34f040edcc7540c0ac318c65d393a4 src/models/list-events-op.ts: id: 82a9f364bb21 last_write_checksum: sha1:32c875df2a181a5aa651f4350a2a7114a8c92bd1 pristine_git_object: 6dcbe739854dac80f72b729ba19b1bfa08c0cc99 src/models/list-features-op.ts: id: d9fcd707ca8f - last_write_checksum: sha1:1b631462ea2042d3b2cf3db4c584d7538411667d - pristine_git_object: 0f260a39a6ffe401412f6ad510b9e752acd42d3e + last_write_checksum: sha1:3fd3c08c46b47667670af0bd2fdbfc2003646540 + pristine_git_object: cc0e37a190877dc4b5c825bf4c20b40fd7600211 src/models/list-plans-op.ts: id: 513cde894485 - last_write_checksum: sha1:34f754fbaf9f8353860ea2e071f22bb020e60e85 - pristine_git_object: bd00d1a7696f58fe1cb085d900bee000ed3bfdfd + last_write_checksum: sha1:400d9136d30023eec420760a4b768ef82d87730e + pristine_git_object: fb4a679c330adb944e5ebf5a5e009874cfa711ec src/models/multi-attach-op.ts: id: 99a2b77c1afc - last_write_checksum: sha1:51625deba18179175ff7d924920417c6c2fe4b8d - pristine_git_object: 38d6d34172a83ce01ffdd3f4d652c8e56c720f74 + last_write_checksum: sha1:6cb0d46694629d904879cc246d2caeb807f15fef + pristine_git_object: 492cd0180ac76fac6db443ca8fe57e500de630d3 src/models/open-customer-portal-op.ts: id: a003eb4172a9 last_write_checksum: sha1:5e672fc975a0336c963181042a770c2a43cbc0e3 pristine_git_object: 4a9318890026bc6e67a8a5b65dc596ae7f25f855 src/models/plan.ts: id: 9e9698a64fe7 - last_write_checksum: sha1:7f9f6c4600f6997b2429107e8a02e7bf030c53e0 - pristine_git_object: edba9d825d88c26f3322be98e78d37ffd2699f99 + last_write_checksum: sha1:2c58e765e585772dfbf346e075dd512887066bda + pristine_git_object: 94a0efba57783761ad79b662e9bf5a418af7bba3 src/models/preview-attach-op.ts: id: 3efc6e3443a7 - last_write_checksum: sha1:2d267bd64f1069e310815cda47a224bd39832aa1 - pristine_git_object: 969faf47c1a15210c4c2bbf5817bd1a85e4c1647 + last_write_checksum: sha1:421a4c5623cc11e12eaf991a4a04ef24de59c5aa + pristine_git_object: e02113f3332de663b62c672bf8a5261217eb5966 src/models/preview-multi-attach-op.ts: id: e4847dc281a6 - last_write_checksum: sha1:0f9c185815e3de7f7705d5c498e351acd74bcebd - pristine_git_object: a0be451fab836ea3c2e6be68d8a0cef79a7a4d4e + last_write_checksum: sha1:19572d0b867d315907b1af65a659226c5f3a606d + pristine_git_object: 7edc30bef99c2775a78908b563e9ec4277d8c03c src/models/preview-update-op.ts: id: fcbbbf3b22ac - last_write_checksum: sha1:cb54804b85295a4d132869d9ba99cc84165ef761 - pristine_git_object: 279424e24e58bdeb5ae76380ea4b4c0d31c7f7b2 + last_write_checksum: sha1:3fc014c21a4ca64973a56f9c096a7e901bfc4a98 + pristine_git_object: 61c8c3978712d9041c65f4e658aa2d51c3ca945f src/models/redeem-referral-code-op.ts: id: 511bf73dc4c6 last_write_checksum: sha1:9ab6622018c82175ea98d2b26eadb4abf08f441a @@ -4829,44 +5181,48 @@ trackedFiles: pristine_git_object: 3774cc1e9bbb80ac592990aa86f8d4a38ee51f29 src/models/setup-payment-op.ts: id: 0e97e999ff3c - last_write_checksum: sha1:c09cf100a8eaedfc307aa7037081eece0db24d82 - pristine_git_object: 888b6921e5d7f7526d33f3dfc3a5c4eceb93e55b + last_write_checksum: sha1:b022bd4843dcbe22911034a4a74911343981dc96 + pristine_git_object: d5b502e7a7692e354a2034f6e09598a364124890 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 - pristine_git_object: 8efd488c024591ba05edff0229dd2070a885be9b + last_write_checksum: sha1:a39f122f28d1ec52ea76e170c097842958905a4e + pristine_git_object: 36e442be77203cf7a280b8112e0a82af5034cec7 + src/models/track-tokens-op.ts: + id: 320b6e3da957 + last_write_checksum: sha1:cbee9918a5d8d2e460565e192324d11930926302 + pristine_git_object: 4f5be60e550e9845ba3426788e8c5a32f52de256 src/models/update-balance-op.ts: id: 69282313a00e last_write_checksum: sha1:d8a5f711a56c32c9dd9fb71b611df33684a3c260 pristine_git_object: bfa2a0d29be530ccbf883a563399b35712623642 src/models/update-customer-op.ts: id: 5d226d30d8e4 - last_write_checksum: sha1:16831408ad752fb0adadeaecc1a720d38c5746dd - pristine_git_object: 7357d58694971374b4bf2554de091af5aef52f77 + last_write_checksum: sha1:becf04aae7b92e22ea19262f9a7016d9f36c3225 + pristine_git_object: c5d5581fb122d73c14b6dc88dd232d7dd49721d2 src/models/update-entity-op.ts: id: c3fdb6479f02 - last_write_checksum: sha1:5807a9f00257b0882601f14bd6456c03bae9c88d - pristine_git_object: 29b3ae109315e2ea45621eccaf06c50193980b4f + last_write_checksum: sha1:9c84ae603141639c01384d1627f1f7395426b7a3 + pristine_git_object: 588298dd027e896a01be3a8f8b5a17b3f5fea363 src/models/update-feature-op.ts: id: 7c27d245784e - last_write_checksum: sha1:fca4d29e843ff678c6f85258ef40668a46da5e42 - pristine_git_object: b1b8e80440aa378657be836028b4bf806c6949b4 + last_write_checksum: sha1:f1460803c791f7ec6ac9ca104ca68984f420fd24 + pristine_git_object: 1564c4312ff7944eaaaa5b8e8a80d4877b97f134 src/models/update-plan-op.ts: id: 54b4f842d3b2 - last_write_checksum: sha1:50535f37ebbe02c0e5c0d38b37dd11d2079e85f1 - pristine_git_object: e9b5f6df0ef52bce3da0622afcad1d012d58112a + last_write_checksum: sha1:5a6859abaffb25cc13585ba20cb0be56b55880dc + pristine_git_object: 085303baf2295275b9a1e0e90fd52635674a4b56 src/sdk/balances.ts: id: 9ad229cb9d64 last_write_checksum: sha1:3e195bbaeea3a9bc5afd4095d92949f3cad0956e pristine_git_object: 571de419ea3321d79acec4bddbb46b1580007115 src/sdk/billing.ts: id: 10905058c4ad - last_write_checksum: sha1:9e9b653cd84c96cbd9b80540a2800b113025d4d4 - pristine_git_object: 199d000c7899a69863745c75661b6137acfbf9fd + last_write_checksum: sha1:0bac363869edd7837d02dc59e165ac9afdd82a3d + pristine_git_object: 52d94e1515ae6bbb96132e0f7d99cc7119a67ad7 src/sdk/customers.ts: id: d33e193e0c00 last_write_checksum: sha1:8d64f03efa17b4ef45a6d67a44d23e2943f1cd8b @@ -4881,8 +5237,8 @@ trackedFiles: pristine_git_object: a7453990fa87b1a6b85837c054b65c019cf0bfa2 src/sdk/features.ts: id: 566b32de367f - last_write_checksum: sha1:612a609c6bb1a82ec00e4a0be87dedb20b2264b6 - pristine_git_object: 436c04f1b77218d922d29b0e00e44bd9ad2755b2 + last_write_checksum: sha1:5ee4b3c49cdf247c53ac2e218b6d313e8c0730c3 + pristine_git_object: 27b4e859c475d0700bf2c048e3befe601c6d21e2 src/sdk/index.ts: id: a857902a703f last_write_checksum: sha1:ed6d64f2a6135349aa8498b8d8cff9ba85c7fb8f @@ -4905,8 +5261,8 @@ trackedFiles: pristine_git_object: f6a3928ecbf5b6a9907bc7d405808d0839848a04 src/sdk/sdk.ts: id: 784571af2f69 - last_write_checksum: sha1:33274a26041219427b3477c52c8bd100f08ba630 - pristine_git_object: da01a652b7e0ec3221579c07c66bab56ff49016b + last_write_checksum: sha1:5cc3886a1cedeb318eebb8136c6c2ca9d01bf380 + pristine_git_object: 5467a329eabcd75d80b1940d153e2888af7164be src/types/async.ts: id: fac8da972f86 last_write_checksum: sha1:3ff07b3feaf390ec1aeb18ff938e139c6c4a9585 @@ -5849,4 +6205,16 @@ examples: 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"} + trackTokens: + speakeasy-default-track-tokens: + parameters: + header: + x-api-version: "2.3.0" + requestBody: + application/json: {"customer_id": "cus_123", "feature_id": "ai_credits", "model_id": "anthropic/claude-sonnet-4-20250514", "input_tokens": 1000, "output_tokens": 500} + responses: + "200": + application/json: {"customer_id": "cus_123", "value": 0.006, "balance": {"feature_id": "ai_credits", "granted": 10, "remaining": 9.994, "usage": 0.006, "unlimited": false, "overage_allowed": false, "max_purchase": null, "next_reset_at": 1773851121437, "breakdown": [{"id": "cus_ent_39qmLooixXLAqMywgXywjAz96rV", "plan_id": "pro_plan", "included_grant": 100, "prepaid_grant": 0, "remaining": 72, "usage": 28, "unlimited": false, "reset": {"interval": "month", "resets_at": 1773851121437}, "price": null, "expires_at": null}]}, "deductions": [{"balance_id": "cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2", "feature_id": "ai_credits", "plan_id": "pro", "reset": {"interval": "month", "resets_at": 1781288736881}, "value": 0.006}]} + "202": + application/json: {"customer_id": "cus_123", "value": 0.006, "balance": {"feature_id": "ai_credits", "granted": 10, "remaining": 9.994, "usage": 0.006, "unlimited": false, "overage_allowed": false, "max_purchase": null, "next_reset_at": 1773851121437, "breakdown": [{"id": "cus_ent_39qmLooixXLAqMywgXywjAz96rV", "plan_id": "pro_plan", "included_grant": 100, "prepaid_grant": 0, "remaining": 72, "usage": 28, "unlimited": false, "reset": {"interval": "month", "resets_at": 1773851121437}, "price": null, "expires_at": null}]}, "deductions": [{"balance_id": "cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2", "feature_id": "ai_credits", "plan_id": "pro", "reset": {"interval": "month", "resets_at": 1781288736881}, "value": 0.006}]} examplesVersion: 1.0.2 diff --git a/packages/sdk/.speakeasy/out.openapi.yaml b/packages/sdk/.speakeasy/out.openapi.yaml index fa093fefe..4bb7e22f6 100644 --- a/packages/sdk/.speakeasy/out.openapi.yaml +++ b/packages/sdk/.speakeasy/out.openapi.yaml @@ -555,8 +555,9 @@ components: - boolean - metered - credit_system + - ai_credit_system type: string - description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools." + description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage)." @@ -580,6 +581,44 @@ components: - metered_feature_id - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -1039,6 +1078,7 @@ components: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -1122,9 +1162,19 @@ components: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. tier_behavior: enum: @@ -1328,8 +1378,9 @@ components: - boolean - metered - credit_system + - ai_credit_system type: string - description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools." + description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage)." @@ -1353,6 +1404,44 @@ components: - metered_feature_id - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -1474,9 +1563,19 @@ components: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration if applicable. tier_behavior: enum: @@ -2249,8 +2348,9 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string - description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools." + description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage)." @@ -2274,6 +2374,44 @@ paths: - metered_feature_id - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -3074,8 +3212,9 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string - description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools." + description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage)." @@ -3099,6 +3238,44 @@ paths: - metered_feature_id - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -3877,8 +4054,9 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string - description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools." + description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage)." @@ -3902,6 +4080,44 @@ paths: - metered_feature_id - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -4330,8 +4546,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -4561,6 +4779,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -4644,9 +4863,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. tier_behavior: enum: @@ -5039,6 +5268,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -5122,9 +5352,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. tier_behavior: enum: @@ -5495,6 +5735,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -5578,9 +5819,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. tier_behavior: enum: @@ -6014,8 +6265,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -6122,6 +6375,8 @@ paths: minLength: 1 pattern: ^[a-zA-Z0-9_-]+$ description: The new ID to use for the plan. Can only be updated if the plan has not been used by any customers. + disable_version: + type: boolean required: - plan_id title: UpdatePlanParams @@ -6231,6 +6486,7 @@ paths: - single_use - continuous_use - credit_system + - ai_credit_system type: string description: The type of the feature display: @@ -6314,9 +6570,19 @@ paths: tiers: type: array items: - anyOf: - - {} - - type: "null" + type: object + properties: + to: + anyOf: + - type: number + - const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. tier_behavior: enum: @@ -6675,7 +6941,13 @@ paths: @param display - Singular and plural display names for the feature in your user interface. (optional) - @param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. (optional) + @param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. (optional) + + @param modelMarkups - Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. (optional) + + @param defaultMarkup - Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. (optional) + + @param providerMarkups - Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. (optional) @param featureId - The ID of the feature to create. @@ -6698,6 +6970,7 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. consumable: @@ -6726,7 +6999,45 @@ paths: required: - metered_feature_id - credit_cost - description: A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. + description: A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. event_names: type: array items: @@ -6772,8 +7083,9 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string - description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools." + description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage)." @@ -6797,6 +7109,44 @@ paths: - metered_feature_id - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -6885,8 +7235,9 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string - description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools." + description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage)." @@ -6910,6 +7261,44 @@ paths: - metered_feature_id - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -6982,8 +7371,9 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string - description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools." + description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage)." @@ -7007,6 +7397,44 @@ paths: - metered_feature_id - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -7098,7 +7526,13 @@ paths: @param display - Singular and plural display names for the feature in your user interface. (optional) - @param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. (optional) + @param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. (optional) + + @param modelMarkups - Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. (optional) + + @param defaultMarkup - Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. (optional) + + @param providerMarkups - Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. (optional) @param archived - Whether the feature is archived. Archived features are hidden from the dashboard. (optional) @@ -7126,6 +7560,7 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string description: The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. consumable: @@ -7154,7 +7589,45 @@ paths: required: - metered_feature_id - credit_cost - description: A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. + description: A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. event_names: type: array items: @@ -7198,8 +7671,9 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string - description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools." + description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage)." @@ -7223,6 +7697,44 @@ paths: - metered_feature_id - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -7573,8 +8085,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -7628,7 +8142,7 @@ paths: - feature_id title: PlanItem description: Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. - description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items. + 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: type: array items: @@ -7717,8 +8231,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -7788,15 +8304,33 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9.007199254740991e+15 + maximum: 9.007199254740991e+15 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). description: Filters selecting items to remove from the plan. @@ -8074,7 +8608,7 @@ 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":1780584084429,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781793684429,"plans":[{"planId":"pro_plan"}]}] }); + const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1781115250101,"plans":[{"planId":"trial_plan"}]},{"startsAt":1782324850101,"plans":[{"planId":"pro_plan"}]}] }); ``` @param customerId - The ID of the customer to create the schedule for. @@ -8325,8 +8859,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -8380,9 +8916,192 @@ paths: - feature_id title: PlanItem description: Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. - description: Override the items in the plan. - additionalProperties: false - description: Customize the plan to schedule. Can override the price, items, or both. + 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: + type: array + items: + type: object + properties: + feature_id: + type: string + description: The ID of the feature to configure. + included: + type: number + description: Number of free units included. Balance resets to this each interval for consumable features. + unlimited: + type: boolean + description: If true, customer has unlimited access to this feature. + reset: + type: object + properties: + interval: + enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + description: Interval at which balance resets (e.g. 'month', 'year'). For consumable features only. + interval_count: + type: number + description: Number of intervals between resets. Defaults to 1. + required: + - interval + description: Reset configuration for consumable features. Omit for non-consumable features like seats. + price: + type: object + properties: + amount: + type: number + description: Price per billing_units after included usage. Either 'amount' or 'tiers' is required. + tiers: + type: array + items: + type: object + properties: + to: {} + amount: {} + flat_amount: {} + description: Tiered pricing. Either 'amount' or 'tiers' is required. + tier_behavior: + enum: + - graduated + - volume + type: string + interval: + enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + description: Billing interval. For consumable features, should match reset.interval. + interval_count: + type: number + default: 1 + description: Number of intervals per billing cycle. Defaults to 1. + billing_units: + type: number + default: 1 + description: Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200). + billing_method: + enum: + - prepaid + - usage_based + type: string + description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." + max_purchase: + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. + required: + - interval + - billing_method + description: Pricing for usage beyond included units. Omit for free features. + proration: + type: object + properties: + on_increase: + enum: + - bill_immediately + - prorate_immediately + - prorate_next_cycle + - bill_next_cycle + type: string + description: Billing behavior when quantity increases mid-cycle. + on_decrease: + enum: + - prorate + - prorate_immediately + - prorate_next_cycle + - none + - no_prorations + type: string + description: Credit behavior when quantity decreases mid-cycle. + required: + - on_increase + - on_decrease + description: Proration settings for prepaid features. Controls mid-cycle quantity change billing. + rollover: + type: object + properties: + max: + type: number + description: Max rollover units. Omit for unlimited rollover. + max_percentage: + type: number + description: Maximum rollover as a percentage (0-100) of included + prepaid grant. Mutually exclusive with max. + expiry_duration_type: + enum: + - month + - forever + type: string + description: When rolled over units expire. + expiry_duration_length: + type: number + description: Number of periods before expiry. + required: + - expiry_duration_type + description: Rollover config for unused units. If set, unused included units carry over. + required: + - feature_id + title: PlanItem + description: Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. + description: Items to add to the plan. + remove_items: + type: array + items: + type: object + properties: + feature_id: + type: string + description: Match items linked to this feature. + billing_method: + enum: + - prepaid + - usage_based + type: string + description: Match items with this billing method (prepaid or usage_based). + interval: + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9.007199254740991e+15 + maximum: 9.007199254740991e+15 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items that share an interval but differ in count. + title: PlanItemFilter + description: Filter for matching plan items. All provided fields must match (AND). + description: Filters selecting items to remove from the plan. + description: Customize the plan to schedule. Can override price, replace items, or patch items with add_items, remove_items, and update_items. subscription_id: type: string description: A unique ID to identify this subscription. Useful when scheduling the same plan multiple times. @@ -8541,8 +9260,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -8596,9 +9317,192 @@ paths: - feature_id title: PlanItem description: Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. - description: Override the items in the plan. - additionalProperties: false - description: Customize the plan to schedule. Can override the price, items, or both. + 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: + type: array + items: + type: object + properties: + feature_id: + type: string + description: The ID of the feature to configure. + included: + type: number + description: Number of free units included. Balance resets to this each interval for consumable features. + unlimited: + type: boolean + description: If true, customer has unlimited access to this feature. + reset: + type: object + properties: + interval: + enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + description: Interval at which balance resets (e.g. 'month', 'year'). For consumable features only. + interval_count: + type: number + description: Number of intervals between resets. Defaults to 1. + required: + - interval + description: Reset configuration for consumable features. Omit for non-consumable features like seats. + price: + type: object + properties: + amount: + type: number + description: Price per billing_units after included usage. Either 'amount' or 'tiers' is required. + tiers: + type: array + items: + type: object + properties: + to: {} + amount: {} + flat_amount: {} + description: Tiered pricing. Either 'amount' or 'tiers' is required. + tier_behavior: + enum: + - graduated + - volume + type: string + interval: + enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + description: Billing interval. For consumable features, should match reset.interval. + interval_count: + type: number + default: 1 + description: Number of intervals per billing cycle. Defaults to 1. + billing_units: + type: number + default: 1 + description: Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200). + billing_method: + enum: + - prepaid + - usage_based + type: string + description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." + max_purchase: + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. + required: + - interval + - billing_method + description: Pricing for usage beyond included units. Omit for free features. + proration: + type: object + properties: + on_increase: + enum: + - bill_immediately + - prorate_immediately + - prorate_next_cycle + - bill_next_cycle + type: string + description: Billing behavior when quantity increases mid-cycle. + on_decrease: + enum: + - prorate + - prorate_immediately + - prorate_next_cycle + - none + - no_prorations + type: string + description: Credit behavior when quantity decreases mid-cycle. + required: + - on_increase + - on_decrease + description: Proration settings for prepaid features. Controls mid-cycle quantity change billing. + rollover: + type: object + properties: + max: + type: number + description: Max rollover units. Omit for unlimited rollover. + max_percentage: + type: number + description: Maximum rollover as a percentage (0-100) of included + prepaid grant. Mutually exclusive with max. + expiry_duration_type: + enum: + - month + - forever + type: string + description: When rolled over units expire. + expiry_duration_length: + type: number + description: Number of periods before expiry. + required: + - expiry_duration_type + description: Rollover config for unused units. If set, unused included units carry over. + required: + - feature_id + title: PlanItem + description: Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. + description: Items to add to the plan. + remove_items: + type: array + items: + type: object + properties: + feature_id: + type: string + description: Match items linked to this feature. + billing_method: + enum: + - prepaid + - usage_based + type: string + description: Match items with this billing method (prepaid or usage_based). + interval: + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9.007199254740991e+15 + maximum: 9.007199254740991e+15 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items that share an interval but differ in count. + title: PlanItemFilter + description: Filter for matching plan items. All provided fields must match (AND). + description: Filters selecting items to remove from the plan. + description: Customize the plan to schedule. Can override price, replace items, or patch items with add_items, remove_items, and update_items. subscription_id: type: string description: A unique ID to identify this subscription. Useful when scheduling the same plan multiple times. @@ -8928,8 +9832,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -9514,8 +10420,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -9569,7 +10477,7 @@ paths: - feature_id title: PlanItem description: Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. - description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items. + 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: type: array items: @@ -9658,8 +10566,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -9729,15 +10639,33 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9.007199254740991e+15 + maximum: 9.007199254740991e+15 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). description: Filters selecting items to remove from the plan. @@ -10482,8 +11410,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -11371,8 +12301,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -11426,7 +12358,7 @@ paths: - feature_id title: PlanItem description: Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. - description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items. + 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: type: array items: @@ -11515,8 +12447,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -11586,15 +12520,33 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9.007199254740991e+15 + maximum: 9.007199254740991e+15 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). description: Filters selecting items to remove from the plan. @@ -12010,8 +12962,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -12065,7 +13019,7 @@ paths: - feature_id title: PlanItem description: Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. - description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items. + 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: type: array items: @@ -12154,8 +13108,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -12225,15 +13181,33 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9.007199254740991e+15 + maximum: 9.007199254740991e+15 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). description: Filters selecting items to remove from the plan. @@ -12952,8 +13926,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -13007,7 +13983,7 @@ paths: - feature_id title: PlanItem description: Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. - description: Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items. + 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: type: array items: @@ -13096,8 +14072,10 @@ paths: type: string description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go." max_purchase: - type: number - description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. + anyOf: + - type: number + - type: "null" + description: Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. required: - interval - billing_method @@ -13167,15 +14145,33 @@ paths: type: string description: Match items with this billing method (prepaid or usage_based). interval: - enum: - - one_off - - week - - month - - quarter - - semi_annual - - year - type: string - description: Match items with this interval. + anyOf: + - enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + type: string + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + 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: + type: integer + minimum: -9.007199254740991e+15 + maximum: 9.007199254740991e+15 + exclusiveMinimum: 0 + description: Match items with this interval_count. Disambiguates between items that share an interval but differ in count. title: PlanItemFilter description: Filter for matching plan items. All provided fields must match (AND). description: Filters selecting items to remove from the plan. @@ -13846,8 +14842,9 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string - description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools." + description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage)." @@ -13871,6 +14868,44 @@ paths: - metered_feature_id - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -14349,8 +15384,9 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string - description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools." + description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage)." @@ -14374,6 +15410,44 @@ paths: - metered_feature_id - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -15149,6 +16223,392 @@ paths: x-speakeasy-name-override: track parameters: - *a1 + /v1/balances.track_tokens: + post: + operationId: trackTokens + description: >- + Records AI token usage for a customer and returns the updated AI credit balance. + + + Use this after an LLM request when you have input and output token counts. Autumn converts token usage to a dollar amount using the configured model pricing and markup, then tracks that value against the customer's AI credit system. + + + @example + + ```typescript + + // Track one LLM response + + const response = await client.trackTokens({ + + customerId: "cus_123", + featureId: "ai_credits", + modelId: "anthropic/claude-sonnet-4-20250514", + inputTokens: 1000, + outputTokens: 500, + }); + + ``` + + + @param customerId - The ID of the customer. + + @param entityId - The ID of the entity for entity-scoped balances. (optional) + + @param featureId - The ID of the AI credit system feature. Auto-detected from the customer's entitlements if omitted — only required when a customer has multiple AI credit systems. (optional) + + @param modelId - The AI model as '/' (e.g. 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). The provider is the first path segment and must match a provider + model key in models.dev. + + @param inputTokens - Number of non-cached text input tokens consumed. Exclusive of cache and audio token pools. + + @param outputTokens - Number of text output tokens consumed. Exclusive of the reasoning and audio output pools. + + @param cacheReadTokens - Number of cached input tokens read. (optional) + + @param cacheWriteTokens - Number of input tokens written to the cache. (optional) + + @param audioInputTokens - Number of audio input tokens consumed. (optional) + + @param audioOutputTokens - Number of audio output tokens generated. (optional) + + @param reasoningTokens - Number of reasoning tokens generated. (optional) + + @param properties - Additional properties to attach to this usage event. (optional) + + + @returns The dollar value recorded and the updated AI credit system balance. If Autumn is experiencing degraded service from a downstream provider, the API may return 202 after accepting the token usage event for replay so it can be tracked as soon as the service is restored. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + customer_id: + type: string + description: The ID of the customer. + entity_id: + type: string + description: The ID of the entity for entity-scoped balances. + feature_id: + type: string + description: The ID of the AI credit system feature. Auto-detected from the customer's entitlements if omitted — only required when a customer has multiple AI credit systems. + model_id: + type: string + description: The AI model as '/' (e.g. 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). The provider is the first path segment and must match a provider + model key in models.dev. + input_tokens: + type: integer + minimum: 0 + maximum: 9.007199254740991e+15 + description: Number of non-cached text input tokens consumed. Exclusive of cache and audio token pools. + output_tokens: + type: integer + minimum: 0 + maximum: 9.007199254740991e+15 + description: Number of text output tokens consumed. Exclusive of the reasoning and audio output pools. + cache_read_tokens: + type: integer + minimum: 0 + maximum: 9.007199254740991e+15 + description: Number of cached input tokens read. + cache_write_tokens: + type: integer + minimum: 0 + maximum: 9.007199254740991e+15 + description: Number of input tokens written to the cache. + audio_input_tokens: + type: integer + minimum: 0 + maximum: 9.007199254740991e+15 + description: Number of audio input tokens consumed. + audio_output_tokens: + type: integer + minimum: 0 + maximum: 9.007199254740991e+15 + description: Number of audio output tokens generated. + reasoning_tokens: + type: integer + minimum: 0 + maximum: 9.007199254740991e+15 + description: Number of reasoning tokens generated. + properties: + type: object + propertyNames: + type: string + additionalProperties: {} + description: Additional properties to attach to this usage event. + required: + - customer_id + - model_id + - input_tokens + - output_tokens + title: TrackTokensParams + examples: + - customer_id: cus_123 + feature_id: ai_credits + model_id: anthropic/claude-sonnet-4-20250514 + input_tokens: 1000 + output_tokens: 500 + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + customer_id: + type: string + description: The ID of the customer whose usage was tracked. + entity_id: + type: string + description: The ID of the entity, if entity-scoped tracking was performed. + event_name: + type: string + description: The event name that was tracked, if event_name was used instead of feature_id. + value: + type: number + description: The amount of usage that was recorded. + balance: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: The updated balance for the tracked feature. Null if tracking by event_name that affects multiple features. + balances: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature. + deductions: + type: array + items: + type: object + properties: + balance_id: + type: string + description: ID of the underlying balance row that was deducted from (customer_entitlement or rollover). + feature_id: + type: string + description: The feature this balance belongs to. + plan_id: + anyOf: + - type: string + - type: "null" + description: ID of the plan/product this balance belongs to. Null when the balance can't be attributed to a single plan (e.g. it spans multiple). + reset: + anyOf: + - type: object + properties: + interval: + anyOf: + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + - const: multiple + description: The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals. + interval_count: + type: number + description: Number of intervals between resets (eg. 2 for bi-monthly). + resets_at: + anyOf: + - type: number + - type: "null" + description: Timestamp when the balance will next reset. + required: + - interval + - resets_at + - type: "null" + description: Reset configuration for the balance this deduction came from, or null if the balance doesn't reset. + value: + type: number + description: Amount deducted from this balance. Positive when usage was consumed, negative when credit was restored (e.g. a refund via negative track value). + required: + - balance_id + - feature_id + - plan_id + - reset + - value + description: Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling. + required: + - customer_id + - value + - balance + examples: + - customer_id: cus_123 + value: 0.006 + balance: + feature_id: ai_credits + granted: 10 + remaining: 9.994 + usage: 0.006 + unlimited: false + overage_allowed: false + max_purchase: null + next_reset_at: 1773851121437 + breakdown: + - id: cus_ent_39qmLooixXLAqMywgXywjAz96rV + plan_id: pro_plan + included_grant: 100 + prepaid_grant: 0 + remaining: 72 + usage: 28 + unlimited: false + reset: + interval: month + resets_at: 1773851121437 + price: null + expires_at: null + deductions: + - balance_id: cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2 + feature_id: ai_credits + plan_id: pro + reset: + interval: month + resets_at: 1781288736881 + value: 0.006 + "202": + description: Accepted. Autumn is experiencing degraded service from a downstream provider, so the token usage event was accepted for replay and will be tracked as soon as the service is restored. + content: + application/json: + schema: + type: object + properties: + customer_id: + type: string + description: The ID of the customer whose usage was tracked. + entity_id: + type: string + description: The ID of the entity, if entity-scoped tracking was performed. + event_name: + type: string + description: The event name that was tracked, if event_name was used instead of feature_id. + value: + type: number + description: The amount of usage that was recorded. + balance: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: The updated balance for the tracked feature. Null if tracking by event_name that affects multiple features. + balances: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - $ref: "#/components/schemas/Balance" + - type: "null" + description: Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature. + deductions: + type: array + items: + type: object + properties: + balance_id: + type: string + description: ID of the underlying balance row that was deducted from (customer_entitlement or rollover). + feature_id: + type: string + description: The feature this balance belongs to. + plan_id: + anyOf: + - type: string + - type: "null" + description: ID of the plan/product this balance belongs to. Null when the balance can't be attributed to a single plan (e.g. it spans multiple). + reset: + anyOf: + - type: object + properties: + interval: + anyOf: + - enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + type: string + - const: multiple + description: The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals. + interval_count: + type: number + description: Number of intervals between resets (eg. 2 for bi-monthly). + resets_at: + anyOf: + - type: number + - type: "null" + description: Timestamp when the balance will next reset. + required: + - interval + - resets_at + - type: "null" + description: Reset configuration for the balance this deduction came from, or null if the balance doesn't reset. + value: + type: number + description: Amount deducted from this balance. Positive when usage was consumed, negative when credit was restored (e.g. a refund via negative track value). + required: + - balance_id + - feature_id + - plan_id + - reset + - value + description: Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling. + required: + - customer_id + - value + - balance + examples: + - customer_id: cus_123 + value: 0.006 + balance: + feature_id: ai_credits + granted: 10 + remaining: 9.994 + usage: 0.006 + unlimited: false + overage_allowed: false + max_purchase: null + next_reset_at: 1773851121437 + breakdown: + - id: cus_ent_39qmLooixXLAqMywgXywjAz96rV + plan_id: pro_plan + included_grant: 100 + prepaid_grant: 0 + remaining: 72 + usage: 28 + unlimited: false + reset: + interval: month + resets_at: 1773851121437 + price: null + expires_at: null + deductions: + - balance_id: cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2 + feature_id: ai_credits + plan_id: pro + reset: + interval: month + resets_at: 1781288736881 + value: 0.006 + x-speakeasy-name-override: trackTokens + parameters: + - *a1 /v1/balances.batch_track: post: operationId: batchTrack @@ -15963,8 +17423,9 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string - description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools." + description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage)." @@ -15988,6 +17449,44 @@ paths: - metered_feature_id - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -16444,8 +17943,9 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string - description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools." + description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage)." @@ -16469,6 +17969,44 @@ paths: - metered_feature_id - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -16979,8 +18517,9 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string - description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools." + description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage)." @@ -17004,6 +18543,44 @@ paths: - metered_feature_id - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -17523,8 +19100,9 @@ paths: - boolean - metered - credit_system + - ai_credit_system type: string - description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools." + description: "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing." consumable: type: boolean description: "For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage)." @@ -17548,6 +19126,44 @@ paths: - metered_feature_id - credit_cost description: "For credit_system features: maps metered features to their credit costs." + model_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + input_cost: + type: number + minimum: 0 + output_cost: + type: number + minimum: 0 + - type: "null" + description: Per-model markup overrides for AI credit systems. + default_markup: + type: number + minimum: -100 + description: Default percentage markup for AI credit systems. Use -100 to make usage free. + provider_markups: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + markup: + type: number + minimum: -100 + required: + - markup + - type: "null" + description: Per-provider default markup percentages for AI credit systems. display: type: object properties: @@ -18712,9 +20328,251 @@ webhooks: feature_id: description: The ID of the feature that was added or removed. type: string + item: + description: The item snapshot that was added or removed. + type: object + properties: + feature_id: + description: The ID of the feature this item configures. + type: string + feature: + description: The full feature object if expanded. + type: object + properties: + id: + description: The ID of the feature, used to refer to it in other API calls like /track or /check. + type: string + name: + description: The name of the feature. + anyOf: + - type: string + - type: "null" + type: + description: The type of the feature + type: string + enum: + - static + - boolean + - single_use + - continuous_use + - credit_system + - ai_credit_system + display: + description: Singular and plural display names for the feature. + anyOf: + - type: object + properties: + singular: + description: The singular display name for the feature. + type: string + plural: + description: The plural display name for the feature. + type: string + required: + - singular + - plural + additionalProperties: false + - type: "null" + credit_schema: + description: Credit cost schema for credit system features. + anyOf: + - type: array + items: + type: object + properties: + metered_feature_id: + description: The ID of the metered feature (should be a single_use feature). + type: string + credit_cost: + description: The credit cost of the metered feature. + type: number + required: + - metered_feature_id + - credit_cost + additionalProperties: false + - type: "null" + archived: + description: Whether or not the feature is archived. + anyOf: + - type: boolean + - type: "null" + required: + - id + - type + additionalProperties: false + included: + description: Number of free units included. For consumable features, balance resets to this number each interval. + type: number + unlimited: + description: Whether the customer has unlimited access to this feature. + type: boolean + reset: + description: Reset configuration for consumable features. Null for non-consumable features like seats where usage persists across billing cycles. + anyOf: + - type: object + properties: + interval: + description: The interval at which the feature balance resets (e.g. 'month', 'year'). For consumable features, usage resets to 0 and included units are restored. + type: string + enum: + - one_off + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + interval_count: + description: Number of intervals between resets. Defaults to 1. + type: number + required: + - interval + additionalProperties: false + - type: "null" + price: + description: Pricing configuration for usage beyond included units. Null if feature is entirely free. + anyOf: + - type: object + properties: + amount: + description: Price per billing_units after included usage is consumed. Mutually exclusive with tiers. + type: number + tiers: + description: Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. + type: array + items: + type: object + properties: + to: + anyOf: + - type: number + - type: string + const: inf + amount: + type: number + flat_amount: + type: number + required: + - to + - amount + additionalProperties: false + tier_behavior: + type: string + enum: + - graduated + - volume + interval: + description: Billing interval for this price. For consumable features, should match reset.interval. + type: string + enum: + - one_off + - week + - month + - quarter + - semi_annual + - year + interval_count: + description: Number of intervals per billing cycle. Defaults to 1. + type: number + billing_units: + description: Number of units per price increment. Usage is rounded UP to the nearest billing_units when billed (e.g. billing_units=100 means 101 usage rounds to 200). + type: number + billing_method: + description: "'prepaid' for features like seats where customers pay upfront, 'usage_based' for pay-as-you-go after included usage." + type: string + enum: + - prepaid + - usage_based + max_purchase: + description: Maximum units a customer can purchase beyond included. E.g. if included=100 and max_purchase=300, customer can use up to 400 total before usage is capped. Null for no limit. + anyOf: + - type: number + - type: "null" + required: + - interval + - billing_units + - billing_method + - max_purchase + additionalProperties: false + - type: "null" + display: + description: Display text for showing this item in pricing pages. + type: object + properties: + primary_text: + description: Main display text (e.g. '$10' or '100 messages'). + type: string + secondary_text: + description: Secondary display text (e.g. 'per month' or 'then $0.5 per 100'). + type: string + required: + - primary_text + additionalProperties: false + rollover: + description: Rollover configuration for unused units. If set, unused included units roll over to the next period. + type: object + properties: + max: + description: Maximum rollover units. Null for unlimited rollover. + anyOf: + - type: number + - type: "null" + max_percentage: + description: Maximum rollover as a percentage (0-100) of included + prepaid grant. Mutually exclusive with max. + anyOf: + - type: number + - type: "null" + expiry_duration_type: + description: When rolled over units expire. + type: string + enum: + - month + - forever + expiry_duration_length: + description: Number of periods before expiry. + type: number + required: + - max + - expiry_duration_type + additionalProperties: false + proration: + internal: true + type: object + properties: + on_increase: + description: How to handle billing when quantity increases mid-cycle (prepaid features only). + type: string + enum: + - bill_immediately + - prorate_immediately + - prorate_next_cycle + - bill_next_cycle + on_decrease: + description: How to handle credits when quantity decreases mid-cycle (prepaid features only). + type: string + enum: + - prorate + - prorate_immediately + - prorate_next_cycle + - none + - no_prorations + additionalProperties: false + entity_feature_id: + internal: true + type: string + required: + - feature_id + - included + - unlimited + - reset + - price + additionalProperties: false required: - action - feature_id + - item additionalProperties: false required: - action diff --git a/packages/sdk/.speakeasy/workflow.lock b/packages/sdk/.speakeasy/workflow.lock index 4206d4998..9b6aac8db 100644 --- a/packages/sdk/.speakeasy/workflow.lock +++ b/packages/sdk/.speakeasy/workflow.lock @@ -9,8 +9,8 @@ sources: - 2.3.0 Autumn API Stripped: sourceNamespace: autumn-api-stripped - sourceRevisionDigest: sha256:9a5ed970a3c6cb9e483cee7b24cbe55c12817ce5885c03c584393de6f3266aa8 - sourceBlobDigest: sha256:56a78efa96cb2759c5d530712cc0dffad340081eb8430a683d6da44e91f0c2cd + sourceRevisionDigest: sha256:c824d43d040e8f8868487bd96df416077e7a700031fb8e4aa8d9b7f4416c0b85 + sourceBlobDigest: sha256:8c28e2210fb5cc0ab657be232ceccc7c399054ad748291238cd09856cf8f4497 tags: - latest - 2.3.0 @@ -25,10 +25,10 @@ targets: autumn-python: source: Autumn API Stripped sourceNamespace: autumn-api-stripped - sourceRevisionDigest: sha256:9a5ed970a3c6cb9e483cee7b24cbe55c12817ce5885c03c584393de6f3266aa8 - sourceBlobDigest: sha256:56a78efa96cb2759c5d530712cc0dffad340081eb8430a683d6da44e91f0c2cd + sourceRevisionDigest: sha256:c824d43d040e8f8868487bd96df416077e7a700031fb8e4aa8d9b7f4416c0b85 + sourceBlobDigest: sha256:8c28e2210fb5cc0ab657be232ceccc7c399054ad748291238cd09856cf8f4497 codeSamplesNamespace: autumn-api-python-code-samples - codeSamplesRevisionDigest: sha256:7440dc1caf97d47706a84220e478515242fa5f5cde6126dec8120bd902cb66dd + codeSamplesRevisionDigest: sha256:ce9139a7dd0716fa669b0b00113f3c0c2b264a40a90a54225d4a585c4163bddf workflow: workflowVersion: 1.0.0 speakeasyVersion: 1.762.0 diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 08cb82530..4f0031416 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -206,6 +206,37 @@ const response = await client.track({ customerId: "cus_123", eventName: "ai_chat @param async - If true, enqueue the event for asynchronous processing and return 202 immediately. The response will not include balance information. (optional) @returns The usage value recorded, with either a single updated balance or a map of updated balances. If Autumn is experiencing degraded service from a downstream provider, the API may return 202 after accepting the event for replay so it can be tracked as soon as the service is restored. +* [trackTokens](docs/sdks/autumn/README.md#tracktokens) - Records AI token usage for a customer and returns the updated AI credit balance. + +Use this after an LLM request when you have input and output token counts. Autumn converts token usage to a dollar amount using the configured model pricing and markup, then tracks that value against the customer's AI credit system. + +@example +```typescript +// Track one LLM response +const response = await client.trackTokens({ + + customerId: "cus_123", + featureId: "ai_credits", + modelId: "anthropic/claude-sonnet-4-20250514", + inputTokens: 1000, + outputTokens: 500, +}); +``` + +@param customerId - The ID of the customer. +@param entityId - The ID of the entity for entity-scoped balances. (optional) +@param featureId - The ID of the AI credit system feature. Auto-detected from the customer's entitlements if omitted — only required when a customer has multiple AI credit systems. (optional) +@param modelId - The AI model as '/' (e.g. 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). The provider is the first path segment and must match a provider + model key in models.dev. +@param inputTokens - Number of non-cached text input tokens consumed. Exclusive of cache and audio token pools. +@param outputTokens - Number of text output tokens consumed. Exclusive of the reasoning and audio output pools. +@param cacheReadTokens - Number of cached input tokens read. (optional) +@param cacheWriteTokens - Number of input tokens written to the cache. (optional) +@param audioInputTokens - Number of audio input tokens consumed. (optional) +@param audioOutputTokens - Number of audio output tokens generated. (optional) +@param reasoningTokens - Number of reasoning tokens generated. (optional) +@param properties - Additional properties to attach to this usage event. (optional) + +@returns The dollar value recorded and the updated AI credit system balance. If Autumn is experiencing degraded service from a downstream provider, the API may return 202 after accepting the token usage event for replay so it can be tracked as soon as the service is restored. * [batchTrack](docs/sdks/autumn/README.md#batchtrack) - Enqueue up to 1000 usage events for asynchronous processing. Items are validated synchronously up front; validated items are then enqueued via SQS for background deduction by workers. The response returns 202 immediately and does not include balance information. On partial enqueue failure (some items fail to enqueue, others succeed), the endpoint still returns 202 and logs the failures server-side; clients should NOT retry, because retrying re-enqueues the already-succeeded items. A 503 is returned only when zero items were successfully enqueued (queue entirely unavailable) — that case is safe to retry. ### [Balances](docs/sdks/balances/README.md) @@ -274,7 +305,7 @@ 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":1780584084429,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781793684429,"plans":[{"planId":"pro_plan"}]}] }); +const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1781115250101,"plans":[{"planId":"trial_plan"}]},{"startsAt":1782324850101,"plans":[{"planId":"pro_plan"}]}] }); ``` @param customerId - The ID of the customer to create the schedule for. @@ -635,7 +666,10 @@ const response = await client.features.create({ featureId: "advanced-analytics", @param type - The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. @param consumable - Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features. (optional) @param display - Singular and plural display names for the feature in your user interface. (optional) -@param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. (optional) +@param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. (optional) +@param modelMarkups - Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. (optional) +@param defaultMarkup - Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. (optional) +@param providerMarkups - Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. (optional) @param featureId - The ID of the feature to create. @returns The created feature object. @@ -677,7 +711,10 @@ const response = await client.features.update({ featureId: "deprecated-feature", @param type - The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. (optional) @param consumable - Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features. (optional) @param display - Singular and plural display names for the feature in your user interface. (optional) -@param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. (optional) +@param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. (optional) +@param modelMarkups - Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. (optional) +@param defaultMarkup - Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. (optional) +@param providerMarkups - Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. (optional) @param archived - Whether the feature is archived. Archived features are hidden from the dashboard. (optional) @param featureId - The ID of the feature to update. @param newFeatureId - The new ID of the feature. Feature ID can only be updated if it's not being used by any customers. (optional) @@ -800,7 +837,7 @@ 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":1780584084429,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781793684429,"plans":[{"planId":"pro_plan"}]}] }); +const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1781115250101,"plans":[{"planId":"trial_plan"}]},{"startsAt":1782324850101,"plans":[{"planId":"pro_plan"}]}] }); ``` @param customerId - The ID of the customer to create the schedule for. @@ -1181,7 +1218,10 @@ const response = await client.features.create({ featureId: "advanced-analytics", @param type - The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. @param consumable - Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features. (optional) @param display - Singular and plural display names for the feature in your user interface. (optional) -@param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. (optional) +@param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. (optional) +@param modelMarkups - Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. (optional) +@param defaultMarkup - Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. (optional) +@param providerMarkups - Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. (optional) @param featureId - The ID of the feature to create. @returns The created feature object. @@ -1236,7 +1276,10 @@ const response = await client.features.update({ featureId: "deprecated-feature", @param type - The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. (optional) @param consumable - Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features. (optional) @param display - Singular and plural display names for the feature in your user interface. (optional) -@param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. (optional) +@param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. (optional) +@param modelMarkups - Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. (optional) +@param defaultMarkup - Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. (optional) +@param providerMarkups - Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. (optional) @param archived - Whether the feature is archived. Archived features are hidden from the dashboard. (optional) @param featureId - The ID of the feature to update. @param newFeatureId - The new ID of the feature. Feature ID can only be updated if it's not being used by any customers. (optional) @@ -1278,6 +1321,37 @@ const response = await client.track({ customerId: "cus_123", eventName: "ai_chat @param async - If true, enqueue the event for asynchronous processing and return 202 immediately. The response will not include balance information. (optional) @returns The usage value recorded, with either a single updated balance or a map of updated balances. If Autumn is experiencing degraded service from a downstream provider, the API may return 202 after accepting the event for replay so it can be tracked as soon as the service is restored. +- [`trackTokens`](docs/sdks/autumn/README.md#tracktokens) - Records AI token usage for a customer and returns the updated AI credit balance. + +Use this after an LLM request when you have input and output token counts. Autumn converts token usage to a dollar amount using the configured model pricing and markup, then tracks that value against the customer's AI credit system. + +@example +```typescript +// Track one LLM response +const response = await client.trackTokens({ + + customerId: "cus_123", + featureId: "ai_credits", + modelId: "anthropic/claude-sonnet-4-20250514", + inputTokens: 1000, + outputTokens: 500, +}); +``` + +@param customerId - The ID of the customer. +@param entityId - The ID of the entity for entity-scoped balances. (optional) +@param featureId - The ID of the AI credit system feature. Auto-detected from the customer's entitlements if omitted — only required when a customer has multiple AI credit systems. (optional) +@param modelId - The AI model as '/' (e.g. 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). The provider is the first path segment and must match a provider + model key in models.dev. +@param inputTokens - Number of non-cached text input tokens consumed. Exclusive of cache and audio token pools. +@param outputTokens - Number of text output tokens consumed. Exclusive of the reasoning and audio output pools. +@param cacheReadTokens - Number of cached input tokens read. (optional) +@param cacheWriteTokens - Number of input tokens written to the cache. (optional) +@param audioInputTokens - Number of audio input tokens consumed. (optional) +@param audioOutputTokens - Number of audio output tokens generated. (optional) +@param reasoningTokens - Number of reasoning tokens generated. (optional) +@param properties - Additional properties to attach to this usage event. (optional) + +@returns The dollar value recorded and the updated AI credit system balance. If Autumn is experiencing degraded service from a downstream provider, the API may return 202 after accepting the token usage event for replay so it can be tracked as soon as the service is restored. diff --git a/packages/sdk/src/funcs/billing-create-schedule.ts b/packages/sdk/src/funcs/billing-create-schedule.ts index 828fbb7fd..34a3180f8 100644 --- a/packages/sdk/src/funcs/billing-create-schedule.ts +++ b/packages/sdk/src/funcs/billing-create-schedule.ts @@ -34,7 +34,7 @@ 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":1780512803523,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781722403523,"plans":[{"planId":"pro_plan"}]}] }); + * const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1781115250101,"plans":[{"planId":"trial_plan"}]},{"startsAt":1782324850101,"plans":[{"planId":"pro_plan"}]}] }); * ``` * * @param customerId - The ID of the customer to create the schedule for. diff --git a/packages/sdk/src/funcs/features-create.ts b/packages/sdk/src/funcs/features-create.ts index fde611632..494f44cbf 100644 --- a/packages/sdk/src/funcs/features-create.ts +++ b/packages/sdk/src/funcs/features-create.ts @@ -53,7 +53,10 @@ import { Result } from "../types/fp.js"; * @param type - The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. * @param consumable - Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features. (optional) * @param display - Singular and plural display names for the feature in your user interface. (optional) - * @param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. (optional) + * @param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. (optional) + * @param modelMarkups - Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. (optional) + * @param defaultMarkup - Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. (optional) + * @param providerMarkups - Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. (optional) * @param featureId - The ID of the feature to create. * * @returns The created feature object. diff --git a/packages/sdk/src/funcs/features-update.ts b/packages/sdk/src/funcs/features-update.ts index c5c55893e..0e8b430aa 100644 --- a/packages/sdk/src/funcs/features-update.ts +++ b/packages/sdk/src/funcs/features-update.ts @@ -47,7 +47,10 @@ import { Result } from "../types/fp.js"; * @param type - The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. (optional) * @param consumable - Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features. (optional) * @param display - Singular and plural display names for the feature in your user interface. (optional) - * @param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. (optional) + * @param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. (optional) + * @param modelMarkups - Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. (optional) + * @param defaultMarkup - Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. (optional) + * @param providerMarkups - Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. (optional) * @param archived - Whether the feature is archived. Archived features are hidden from the dashboard. (optional) * @param featureId - The ID of the feature to update. * @param newFeatureId - The new ID of the feature. Feature ID can only be updated if it's not being used by any customers. (optional) diff --git a/packages/sdk/src/funcs/track-tokens.ts b/packages/sdk/src/funcs/track-tokens.ts new file mode 100644 index 000000000..e838c44ac --- /dev/null +++ b/packages/sdk/src/funcs/track-tokens.ts @@ -0,0 +1,196 @@ +/* + * 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"; + +/** + * Records AI token usage for a customer and returns the updated AI credit balance. + * + * Use this after an LLM request when you have input and output token counts. Autumn converts token usage to a dollar amount using the configured model pricing and markup, then tracks that value against the customer's AI credit system. + * + * @example + * ```typescript + * // Track one LLM response + * const response = await client.trackTokens({ + * + * customerId: "cus_123", + * featureId: "ai_credits", + * modelId: "anthropic/claude-sonnet-4-20250514", + * inputTokens: 1000, + * outputTokens: 500, + * }); + * ``` + * + * @param customerId - The ID of the customer. + * @param entityId - The ID of the entity for entity-scoped balances. (optional) + * @param featureId - The ID of the AI credit system feature. Auto-detected from the customer's entitlements if omitted — only required when a customer has multiple AI credit systems. (optional) + * @param modelId - The AI model as '/' (e.g. 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). The provider is the first path segment and must match a provider + model key in models.dev. + * @param inputTokens - Number of non-cached text input tokens consumed. Exclusive of cache and audio token pools. + * @param outputTokens - Number of text output tokens consumed. Exclusive of the reasoning and audio output pools. + * @param cacheReadTokens - Number of cached input tokens read. (optional) + * @param cacheWriteTokens - Number of input tokens written to the cache. (optional) + * @param audioInputTokens - Number of audio input tokens consumed. (optional) + * @param audioOutputTokens - Number of audio output tokens generated. (optional) + * @param reasoningTokens - Number of reasoning tokens generated. (optional) + * @param properties - Additional properties to attach to this usage event. (optional) + * + * @returns The dollar value recorded and the updated AI credit system balance. If Autumn is experiencing degraded service from a downstream provider, the API may return 202 after accepting the token usage event for replay so it can be tracked as soon as the service is restored. + */ +export function trackTokens( + client: AutumnCore, + request: models.TrackTokensParams, + options?: RequestOptions, +): APIPromise< + Result< + models.TrackTokensResponse, + | AutumnError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + request, + options, + )); +} + +async function $do( + client: AutumnCore, + request: models.TrackTokensParams, + options?: RequestOptions, +): Promise< + [ + Result< + models.TrackTokensResponse, + | AutumnError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const parsed = safeParse( + request, + (value) => z.parse(models.TrackTokensParams$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/balances.track_tokens")(); + + 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: "trackTokens", + 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.TrackTokensResponse, + | AutumnError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.json(200, models.TrackTokensResponse$inboundSchema), + M.json(202, models.TrackTokensResponse$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 8594c479a..13f0de38f 100644 --- a/packages/sdk/src/models/attach-op.ts +++ b/packages/sdk/src/models/attach-op.ts @@ -179,9 +179,9 @@ export type AttachItemPrice = { */ billingMethod: AttachItemBillingMethod; /** - * 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. */ - maxPurchase?: number | undefined; + maxPurchase?: number | null | undefined; }; /** @@ -410,9 +410,9 @@ export type AttachAddItemPrice = { */ billingMethod: AttachAddItemBillingMethod; /** - * 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. */ - maxPurchase?: number | undefined; + maxPurchase?: number | null | undefined; }; /** @@ -546,10 +546,22 @@ export type AttachRemoveItemBillingMethod = ClosedEnum< typeof AttachRemoveItemBillingMethod >; -/** - * Match items with this interval. - */ -export const AttachRemoveItemInterval = { +export const AttachIntervalRemoveItemEnum2 = { + OneOff: "one_off", + Minute: "minute", + Hour: "hour", + Day: "day", + Week: "week", + Month: "month", + Quarter: "quarter", + SemiAnnual: "semi_annual", + Year: "year", +} as const; +export type AttachIntervalRemoveItemEnum2 = ClosedEnum< + typeof AttachIntervalRemoveItemEnum2 +>; + +export const AttachIntervalRemoveItemEnum1 = { OneOff: "one_off", Week: "week", Month: "month", @@ -557,13 +569,17 @@ export const AttachRemoveItemInterval = { SemiAnnual: "semi_annual", Year: "year", } as const; -/** - * Match items with this interval. - */ -export type AttachRemoveItemInterval = ClosedEnum< - typeof AttachRemoveItemInterval +export type AttachIntervalRemoveItemEnum1 = ClosedEnum< + typeof AttachIntervalRemoveItemEnum1 >; +/** + * 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. + */ +export type AttachIntervalUnion = + | AttachIntervalRemoveItemEnum1 + | AttachIntervalRemoveItemEnum2; + /** * Filter for matching plan items. All provided fields must match (AND). */ @@ -577,9 +593,16 @@ export type AttachPlanItemFilter = { */ billingMethod?: AttachRemoveItemBillingMethod | undefined; /** - * Match items with this interval. + * 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?: AttachRemoveItemInterval | undefined; + interval?: + | AttachIntervalRemoveItemEnum1 + | AttachIntervalRemoveItemEnum2 + | undefined; + /** + * Match items with this interval_count. Disambiguates between items that share an interval but differ in count. + */ + intervalCount?: number | undefined; }; /** @@ -638,7 +661,7 @@ export type AttachCustomize = { */ price?: AttachBasePrice | null | undefined; /** - * Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items. + * Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items. */ items?: Array | undefined; /** @@ -1124,7 +1147,7 @@ export type AttachItemPrice$Outbound = { interval_count: number; billing_units: number; billing_method: string; - max_purchase?: number | undefined; + max_purchase?: number | null | undefined; }; /** @internal */ @@ -1140,7 +1163,7 @@ export const AttachItemPrice$outboundSchema: z.ZodMiniType< intervalCount: z._default(z.number(), 1), billingUnits: z._default(z.number(), 1), billingMethod: AttachItemBillingMethod$outboundSchema, - maxPurchase: z.optional(z.number()), + maxPurchase: z.optional(z.nullable(z.number())), }), z.transform((v) => { return remap$(v, { @@ -1387,7 +1410,7 @@ export type AttachAddItemPrice$Outbound = { interval_count: number; billing_units: number; billing_method: string; - max_purchase?: number | undefined; + max_purchase?: number | null | undefined; }; /** @internal */ @@ -1403,7 +1426,7 @@ export const AttachAddItemPrice$outboundSchema: z.ZodMiniType< intervalCount: z._default(z.number(), 1), billingUnits: z._default(z.number(), 1), billingMethod: AttachAddItemBillingMethod$outboundSchema, - maxPurchase: z.optional(z.number()), + maxPurchase: z.optional(z.nullable(z.number())), }), z.transform((v) => { return remap$(v, { @@ -1552,15 +1575,41 @@ export const AttachRemoveItemBillingMethod$outboundSchema: z.ZodMiniEnum< > = z.enum(AttachRemoveItemBillingMethod); /** @internal */ -export const AttachRemoveItemInterval$outboundSchema: z.ZodMiniEnum< - typeof AttachRemoveItemInterval -> = z.enum(AttachRemoveItemInterval); +export const AttachIntervalRemoveItemEnum2$outboundSchema: z.ZodMiniEnum< + typeof AttachIntervalRemoveItemEnum2 +> = z.enum(AttachIntervalRemoveItemEnum2); + +/** @internal */ +export const AttachIntervalRemoveItemEnum1$outboundSchema: z.ZodMiniEnum< + typeof AttachIntervalRemoveItemEnum1 +> = z.enum(AttachIntervalRemoveItemEnum1); + +/** @internal */ +export type AttachIntervalUnion$Outbound = string | string; + +/** @internal */ +export const AttachIntervalUnion$outboundSchema: z.ZodMiniType< + AttachIntervalUnion$Outbound, + AttachIntervalUnion +> = smartUnion([ + AttachIntervalRemoveItemEnum1$outboundSchema, + AttachIntervalRemoveItemEnum2$outboundSchema, +]); + +export function attachIntervalUnionToJSON( + attachIntervalUnion: AttachIntervalUnion, +): string { + return JSON.stringify( + AttachIntervalUnion$outboundSchema.parse(attachIntervalUnion), + ); +} /** @internal */ export type AttachPlanItemFilter$Outbound = { feature_id?: string | undefined; billing_method?: string | undefined; - interval?: string | undefined; + interval?: string | string | undefined; + interval_count?: number | undefined; }; /** @internal */ @@ -1571,12 +1620,19 @@ export const AttachPlanItemFilter$outboundSchema: z.ZodMiniType< z.object({ featureId: z.optional(z.string()), billingMethod: z.optional(AttachRemoveItemBillingMethod$outboundSchema), - interval: z.optional(AttachRemoveItemInterval$outboundSchema), + interval: z.optional( + smartUnion([ + AttachIntervalRemoveItemEnum1$outboundSchema, + AttachIntervalRemoveItemEnum2$outboundSchema, + ]), + ), + intervalCount: z.optional(z.int()), }), z.transform((v) => { return remap$(v, { featureId: "feature_id", billingMethod: "billing_method", + intervalCount: "interval_count", }); }), ); diff --git a/packages/sdk/src/models/balance.ts b/packages/sdk/src/models/balance.ts index 4b4ccd5b5..942beabe2 100644 --- a/packages/sdk/src/models/balance.ts +++ b/packages/sdk/src/models/balance.ts @@ -13,15 +13,16 @@ import { smartUnion } from "../types/smart-union.js"; import { SDKValidationError } from "./sdk-validation-error.js"; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export const BalanceType = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export type BalanceType = OpenEnum; @@ -36,6 +37,16 @@ export type BalanceCreditSchema = { creditCost: number; }; +export type BalanceModelMarkups = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type BalanceProviderMarkups = { + markup: number; +}; + /** * Display names for the feature in billing UI and customer-facing components. */ @@ -63,7 +74,7 @@ export type BalanceFeature = { */ name: string; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ type: BalanceType; /** @@ -78,6 +89,18 @@ export type BalanceFeature = { * For credit_system features: maps metered features to their credit costs. */ creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. + */ + modelMarkups?: { [k: string]: BalanceModelMarkups } | null | undefined; + /** + * Default percentage markup for AI credit systems. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. + */ + providerMarkups?: { [k: string]: BalanceProviderMarkups } | null | undefined; /** * Display names for the feature in billing UI and customer-facing components. */ @@ -121,6 +144,14 @@ export type BalanceReset = { resetsAt: number | null; }; +export type BalanceTo = number | string; + +export type BalanceTier = { + to: number | string; + amount: number; + flatAmount?: number | undefined; +}; + /** * How tiers are applied: graduated (split across bands) or volume (flat rate for the matched tier). */ @@ -153,7 +184,7 @@ export type BalancePrice = { /** * Tiered pricing configuration if applicable. */ - tiers?: Array | undefined; + tiers?: Array | undefined; /** * How tiers are applied: graduated (split across bands) or volume (flat rate for the matched tier). */ @@ -304,6 +335,52 @@ export function balanceCreditSchemaFromJSON( ); } +/** @internal */ +export const BalanceModelMarkups$inboundSchema: z.ZodMiniType< + BalanceModelMarkups, + unknown +> = z.pipe( + z.object({ + markup: types.optional(types.number()), + input_cost: types.optional(types.number()), + output_cost: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "input_cost": "inputCost", + "output_cost": "outputCost", + }); + }), +); + +export function balanceModelMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => BalanceModelMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'BalanceModelMarkups' from JSON`, + ); +} + +/** @internal */ +export const BalanceProviderMarkups$inboundSchema: z.ZodMiniType< + BalanceProviderMarkups, + unknown +> = z.object({ + markup: types.number(), +}); + +export function balanceProviderMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => BalanceProviderMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'BalanceProviderMarkups' from JSON`, + ); +} + /** @internal */ export const BalanceDisplay$inboundSchema: z.ZodMiniType< BalanceDisplay, @@ -337,13 +414,27 @@ export const BalanceFeature$inboundSchema: z.ZodMiniType< credit_schema: types.optional( z.array(z.lazy(() => BalanceCreditSchema$inboundSchema)), ), - display: types.optional(z.lazy(() => BalanceDisplay$inboundSchema)), + model_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => BalanceModelMarkups$inboundSchema), + ))), + default_markup: types.optional(types.number()), + provider_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => BalanceProviderMarkups$inboundSchema), + ))), + display: types.optional(z.lazy(() => + BalanceDisplay$inboundSchema + )), archived: types.boolean(), }), z.transform((v) => { return remap$(v, { "event_names": "eventNames", "credit_schema": "creditSchema", + "model_markups": "modelMarkups", + "default_markup": "defaultMarkup", + "provider_markups": "providerMarkups", }); }), ); @@ -406,6 +497,45 @@ export function balanceResetFromJSON( ); } +/** @internal */ +export const BalanceTo$inboundSchema: z.ZodMiniType = + smartUnion([types.number(), types.string()]); + +export function balanceToFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => BalanceTo$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'BalanceTo' from JSON`, + ); +} + +/** @internal */ +export const BalanceTier$inboundSchema: z.ZodMiniType = z + .pipe( + z.object({ + to: smartUnion([types.number(), types.string()]), + amount: types.number(), + flat_amount: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "flat_amount": "flatAmount", + }); + }), + ); + +export function balanceTierFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => BalanceTier$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'BalanceTier' from JSON`, + ); +} + /** @internal */ export const BalanceTierBehavior$inboundSchema: z.ZodMiniType< BalanceTierBehavior, @@ -423,7 +553,7 @@ export const BalancePrice$inboundSchema: z.ZodMiniType = z.pipe( z.object({ amount: types.optional(types.number()), - tiers: types.optional(z.array(types.nullable(z.any()))), + tiers: types.optional(z.array(z.lazy(() => BalanceTier$inboundSchema))), tier_behavior: types.optional(BalanceTierBehavior$inboundSchema), billing_units: types.number(), billing_method: BalanceBillingMethod$inboundSchema, diff --git a/packages/sdk/src/models/billing-update-op.ts b/packages/sdk/src/models/billing-update-op.ts index 53ddec09c..1c72c100a 100644 --- a/packages/sdk/src/models/billing-update-op.ts +++ b/packages/sdk/src/models/billing-update-op.ts @@ -183,9 +183,9 @@ export type BillingUpdateItemPrice = { */ billingMethod: BillingUpdateItemBillingMethod; /** - * 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. */ - maxPurchase?: number | undefined; + maxPurchase?: number | null | undefined; }; /** @@ -418,9 +418,9 @@ export type BillingUpdateAddItemPrice = { */ billingMethod: BillingUpdateAddItemBillingMethod; /** - * 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. */ - maxPurchase?: number | undefined; + maxPurchase?: number | null | undefined; }; /** @@ -554,10 +554,22 @@ export type BillingUpdateRemoveItemBillingMethod = ClosedEnum< typeof BillingUpdateRemoveItemBillingMethod >; -/** - * Match items with this interval. - */ -export const BillingUpdateRemoveItemInterval = { +export const BillingUpdateIntervalRemoveItemEnum2 = { + OneOff: "one_off", + Minute: "minute", + Hour: "hour", + Day: "day", + Week: "week", + Month: "month", + Quarter: "quarter", + SemiAnnual: "semi_annual", + Year: "year", +} as const; +export type BillingUpdateIntervalRemoveItemEnum2 = ClosedEnum< + typeof BillingUpdateIntervalRemoveItemEnum2 +>; + +export const BillingUpdateIntervalRemoveItemEnum1 = { OneOff: "one_off", Week: "week", Month: "month", @@ -565,13 +577,17 @@ export const BillingUpdateRemoveItemInterval = { SemiAnnual: "semi_annual", Year: "year", } as const; -/** - * Match items with this interval. - */ -export type BillingUpdateRemoveItemInterval = ClosedEnum< - typeof BillingUpdateRemoveItemInterval +export type BillingUpdateIntervalRemoveItemEnum1 = ClosedEnum< + typeof BillingUpdateIntervalRemoveItemEnum1 >; +/** + * 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. + */ +export type BillingUpdateIntervalUnion = + | BillingUpdateIntervalRemoveItemEnum1 + | BillingUpdateIntervalRemoveItemEnum2; + /** * Filter for matching plan items. All provided fields must match (AND). */ @@ -585,9 +601,16 @@ export type BillingUpdatePlanItemFilter = { */ billingMethod?: BillingUpdateRemoveItemBillingMethod | undefined; /** - * Match items with this interval. + * 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?: BillingUpdateRemoveItemInterval | undefined; + interval?: + | BillingUpdateIntervalRemoveItemEnum1 + | BillingUpdateIntervalRemoveItemEnum2 + | undefined; + /** + * Match items with this interval_count. Disambiguates between items that share an interval but differ in count. + */ + intervalCount?: number | undefined; }; /** @@ -648,7 +671,7 @@ export type BillingUpdateCustomize = { */ price?: BillingUpdateBasePrice | null | undefined; /** - * Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items. + * Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items. */ items?: Array | undefined; /** @@ -1080,7 +1103,7 @@ export type BillingUpdateItemPrice$Outbound = { interval_count: number; billing_units: number; billing_method: string; - max_purchase?: number | undefined; + max_purchase?: number | null | undefined; }; /** @internal */ @@ -1098,7 +1121,7 @@ export const BillingUpdateItemPrice$outboundSchema: z.ZodMiniType< intervalCount: z._default(z.number(), 1), billingUnits: z._default(z.number(), 1), billingMethod: BillingUpdateItemBillingMethod$outboundSchema, - maxPurchase: z.optional(z.number()), + maxPurchase: z.optional(z.nullable(z.number())), }), z.transform((v) => { return remap$(v, { @@ -1353,7 +1376,7 @@ export type BillingUpdateAddItemPrice$Outbound = { interval_count: number; billing_units: number; billing_method: string; - max_purchase?: number | undefined; + max_purchase?: number | null | undefined; }; /** @internal */ @@ -1371,7 +1394,7 @@ export const BillingUpdateAddItemPrice$outboundSchema: z.ZodMiniType< intervalCount: z._default(z.number(), 1), billingUnits: z._default(z.number(), 1), billingMethod: BillingUpdateAddItemBillingMethod$outboundSchema, - maxPurchase: z.optional(z.number()), + maxPurchase: z.optional(z.nullable(z.number())), }), z.transform((v) => { return remap$(v, { @@ -1531,15 +1554,41 @@ export const BillingUpdateRemoveItemBillingMethod$outboundSchema: z.ZodMiniEnum< > = z.enum(BillingUpdateRemoveItemBillingMethod); /** @internal */ -export const BillingUpdateRemoveItemInterval$outboundSchema: z.ZodMiniEnum< - typeof BillingUpdateRemoveItemInterval -> = z.enum(BillingUpdateRemoveItemInterval); +export const BillingUpdateIntervalRemoveItemEnum2$outboundSchema: z.ZodMiniEnum< + typeof BillingUpdateIntervalRemoveItemEnum2 +> = z.enum(BillingUpdateIntervalRemoveItemEnum2); + +/** @internal */ +export const BillingUpdateIntervalRemoveItemEnum1$outboundSchema: z.ZodMiniEnum< + typeof BillingUpdateIntervalRemoveItemEnum1 +> = z.enum(BillingUpdateIntervalRemoveItemEnum1); + +/** @internal */ +export type BillingUpdateIntervalUnion$Outbound = string | string; + +/** @internal */ +export const BillingUpdateIntervalUnion$outboundSchema: z.ZodMiniType< + BillingUpdateIntervalUnion$Outbound, + BillingUpdateIntervalUnion +> = smartUnion([ + BillingUpdateIntervalRemoveItemEnum1$outboundSchema, + BillingUpdateIntervalRemoveItemEnum2$outboundSchema, +]); + +export function billingUpdateIntervalUnionToJSON( + billingUpdateIntervalUnion: BillingUpdateIntervalUnion, +): string { + return JSON.stringify( + BillingUpdateIntervalUnion$outboundSchema.parse(billingUpdateIntervalUnion), + ); +} /** @internal */ export type BillingUpdatePlanItemFilter$Outbound = { feature_id?: string | undefined; billing_method?: string | undefined; - interval?: string | undefined; + interval?: string | string | undefined; + interval_count?: number | undefined; }; /** @internal */ @@ -1552,12 +1601,19 @@ export const BillingUpdatePlanItemFilter$outboundSchema: z.ZodMiniType< billingMethod: z.optional( BillingUpdateRemoveItemBillingMethod$outboundSchema, ), - interval: z.optional(BillingUpdateRemoveItemInterval$outboundSchema), + interval: z.optional( + smartUnion([ + BillingUpdateIntervalRemoveItemEnum1$outboundSchema, + BillingUpdateIntervalRemoveItemEnum2$outboundSchema, + ]), + ), + intervalCount: z.optional(z.int()), }), z.transform((v) => { return remap$(v, { featureId: "feature_id", billingMethod: "billing_method", + intervalCount: "interval_count", }); }), ); diff --git a/packages/sdk/src/models/check-op.ts b/packages/sdk/src/models/check-op.ts index b6c962e14..ec02ef12b 100644 --- a/packages/sdk/src/models/check-op.ts +++ b/packages/sdk/src/models/check-op.ts @@ -71,15 +71,16 @@ export type CheckParams = { }; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export const FlagType2 = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export type FlagType2 = OpenEnum; @@ -94,6 +95,16 @@ export type CheckCreditSchema2 = { creditCost: number; }; +export type CheckModelMarkups2 = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type CheckProviderMarkups2 = { + markup: number; +}; + /** * Display names for the feature in billing UI and customer-facing components. */ @@ -121,7 +132,7 @@ export type CheckFeature2 = { */ name: string; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ type: FlagType2; /** @@ -136,6 +147,18 @@ export type CheckFeature2 = { * For credit_system features: maps metered features to their credit costs. */ creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. + */ + modelMarkups?: { [k: string]: CheckModelMarkups2 } | null | undefined; + /** + * Default percentage markup for AI credit systems. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. + */ + providerMarkups?: { [k: string]: CheckProviderMarkups2 } | null | undefined; /** * Display names for the feature in billing UI and customer-facing components. */ @@ -563,15 +586,16 @@ export type CheckResponseBody2 = { }; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export const FlagType1 = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export type FlagType1 = OpenEnum; @@ -586,6 +610,16 @@ export type CheckCreditSchema1 = { creditCost: number; }; +export type CheckModelMarkups1 = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type CheckProviderMarkups1 = { + markup: number; +}; + /** * Display names for the feature in billing UI and customer-facing components. */ @@ -613,7 +647,7 @@ export type CheckFeature1 = { */ name: string; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ type: FlagType1; /** @@ -628,6 +662,18 @@ export type CheckFeature1 = { * For credit_system features: maps metered features to their credit costs. */ creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. + */ + modelMarkups?: { [k: string]: CheckModelMarkups1 } | null | undefined; + /** + * Default percentage markup for AI credit systems. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. + */ + providerMarkups?: { [k: string]: CheckProviderMarkups1 } | null | undefined; /** * Display names for the feature in billing UI and customer-facing components. */ @@ -1159,6 +1205,52 @@ export function checkCreditSchema2FromJSON( ); } +/** @internal */ +export const CheckModelMarkups2$inboundSchema: z.ZodMiniType< + CheckModelMarkups2, + unknown +> = z.pipe( + z.object({ + markup: types.optional(types.number()), + input_cost: types.optional(types.number()), + output_cost: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "input_cost": "inputCost", + "output_cost": "outputCost", + }); + }), +); + +export function checkModelMarkups2FromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => CheckModelMarkups2$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CheckModelMarkups2' from JSON`, + ); +} + +/** @internal */ +export const CheckProviderMarkups2$inboundSchema: z.ZodMiniType< + CheckProviderMarkups2, + unknown +> = z.object({ + markup: types.number(), +}); + +export function checkProviderMarkups2FromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => CheckProviderMarkups2$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CheckProviderMarkups2' from JSON`, + ); +} + /** @internal */ export const FlagDisplay2$inboundSchema: z.ZodMiniType = z.object({ @@ -1190,13 +1282,27 @@ export const CheckFeature2$inboundSchema: z.ZodMiniType< credit_schema: types.optional( z.array(z.lazy(() => CheckCreditSchema2$inboundSchema)), ), - display: types.optional(z.lazy(() => FlagDisplay2$inboundSchema)), + model_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => CheckModelMarkups2$inboundSchema), + ))), + default_markup: types.optional(types.number()), + provider_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => CheckProviderMarkups2$inboundSchema), + ))), + display: types.optional(z.lazy(() => + FlagDisplay2$inboundSchema + )), archived: types.boolean(), }), z.transform((v) => { return remap$(v, { "event_names": "eventNames", "credit_schema": "creditSchema", + "model_markups": "modelMarkups", + "default_markup": "defaultMarkup", + "provider_markups": "providerMarkups", }); }), ); @@ -1656,6 +1762,52 @@ export function checkCreditSchema1FromJSON( ); } +/** @internal */ +export const CheckModelMarkups1$inboundSchema: z.ZodMiniType< + CheckModelMarkups1, + unknown +> = z.pipe( + z.object({ + markup: types.optional(types.number()), + input_cost: types.optional(types.number()), + output_cost: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "input_cost": "inputCost", + "output_cost": "outputCost", + }); + }), +); + +export function checkModelMarkups1FromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => CheckModelMarkups1$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CheckModelMarkups1' from JSON`, + ); +} + +/** @internal */ +export const CheckProviderMarkups1$inboundSchema: z.ZodMiniType< + CheckProviderMarkups1, + unknown +> = z.object({ + markup: types.number(), +}); + +export function checkProviderMarkups1FromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => CheckProviderMarkups1$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CheckProviderMarkups1' from JSON`, + ); +} + /** @internal */ export const FlagDisplay1$inboundSchema: z.ZodMiniType = z.object({ @@ -1687,13 +1839,27 @@ export const CheckFeature1$inboundSchema: z.ZodMiniType< credit_schema: types.optional( z.array(z.lazy(() => CheckCreditSchema1$inboundSchema)), ), - display: types.optional(z.lazy(() => FlagDisplay1$inboundSchema)), + model_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => CheckModelMarkups1$inboundSchema), + ))), + default_markup: types.optional(types.number()), + provider_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => CheckProviderMarkups1$inboundSchema), + ))), + display: types.optional(z.lazy(() => + FlagDisplay1$inboundSchema + )), archived: types.boolean(), }), z.transform((v) => { return remap$(v, { "event_names": "eventNames", "credit_schema": "creditSchema", + "model_markups": "modelMarkups", + "default_markup": "defaultMarkup", + "provider_markups": "providerMarkups", }); }), ); diff --git a/packages/sdk/src/models/create-entity-op.ts b/packages/sdk/src/models/create-entity-op.ts index 712cf74bd..4b6deea02 100644 --- a/packages/sdk/src/models/create-entity-op.ts +++ b/packages/sdk/src/models/create-entity-op.ts @@ -269,15 +269,16 @@ export type CreateEntityPurchase = { }; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export const CreateEntityType = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export type CreateEntityType = OpenEnum; @@ -292,6 +293,16 @@ export type CreateEntityCreditSchema = { creditCost: number; }; +export type CreateEntityModelMarkups = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type CreateEntityProviderMarkups = { + markup: number; +}; + /** * Display names for the feature in billing UI and customer-facing components. */ @@ -319,7 +330,7 @@ export type CreateEntityFeature = { */ name: string; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ type: CreateEntityType; /** @@ -334,6 +345,21 @@ export type CreateEntityFeature = { * For credit_system features: maps metered features to their credit costs. */ creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. + */ + modelMarkups?: { [k: string]: CreateEntityModelMarkups } | null | undefined; + /** + * Default percentage markup for AI credit systems. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. + */ + providerMarkups?: + | { [k: string]: CreateEntityProviderMarkups } + | null + | undefined; /** * Display names for the feature in billing UI and customer-facing components. */ @@ -877,6 +903,52 @@ export function createEntityCreditSchemaFromJSON( ); } +/** @internal */ +export const CreateEntityModelMarkups$inboundSchema: z.ZodMiniType< + CreateEntityModelMarkups, + unknown +> = z.pipe( + z.object({ + markup: types.optional(types.number()), + input_cost: types.optional(types.number()), + output_cost: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "input_cost": "inputCost", + "output_cost": "outputCost", + }); + }), +); + +export function createEntityModelMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => CreateEntityModelMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CreateEntityModelMarkups' from JSON`, + ); +} + +/** @internal */ +export const CreateEntityProviderMarkups$inboundSchema: z.ZodMiniType< + CreateEntityProviderMarkups, + unknown +> = z.object({ + markup: types.number(), +}); + +export function createEntityProviderMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => CreateEntityProviderMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CreateEntityProviderMarkups' from JSON`, + ); +} + /** @internal */ export const CreateEntityDisplay$inboundSchema: z.ZodMiniType< CreateEntityDisplay, @@ -910,13 +982,27 @@ export const CreateEntityFeature$inboundSchema: z.ZodMiniType< credit_schema: types.optional( z.array(z.lazy(() => CreateEntityCreditSchema$inboundSchema)), ), - display: types.optional(z.lazy(() => CreateEntityDisplay$inboundSchema)), + model_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => CreateEntityModelMarkups$inboundSchema), + ))), + default_markup: types.optional(types.number()), + provider_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => CreateEntityProviderMarkups$inboundSchema), + ))), + display: types.optional(z.lazy(() => + CreateEntityDisplay$inboundSchema + )), archived: types.boolean(), }), z.transform((v) => { return remap$(v, { "event_names": "eventNames", "credit_schema": "creditSchema", + "model_markups": "modelMarkups", + "default_markup": "defaultMarkup", + "provider_markups": "providerMarkups", }); }), ); diff --git a/packages/sdk/src/models/create-feature-op.ts b/packages/sdk/src/models/create-feature-op.ts index 74a7f92cc..cc88593cb 100644 --- a/packages/sdk/src/models/create-feature-op.ts +++ b/packages/sdk/src/models/create-feature-op.ts @@ -18,31 +18,42 @@ export type CreateFeatureGlobals = { /** * The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. */ -export const CreateFeatureTypeRequest = { +export const CreateFeatureTypeRequestBody = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** * The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. */ -export type CreateFeatureTypeRequest = ClosedEnum< - typeof CreateFeatureTypeRequest +export type CreateFeatureTypeRequestBody = ClosedEnum< + typeof CreateFeatureTypeRequestBody >; /** * Singular and plural display names for the feature in your user interface. */ -export type CreateFeatureDisplayRequest = { +export type CreateFeatureDisplayRequestBody = { singular: string; plural: string; }; -export type CreateFeatureCreditSchemaRequest = { +export type CreateFeatureCreditSchemaRequestBody = { meteredFeatureId: string; creditCost: number; }; +export type CreateFeatureModelMarkupsRequest = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type CreateFeatureProviderMarkupsRequest = { + markup: number; +}; + export type CreateFeatureParams = { /** * The name of the feature. @@ -51,7 +62,7 @@ export type CreateFeatureParams = { /** * The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. */ - type: CreateFeatureTypeRequest; + type: CreateFeatureTypeRequestBody; /** * Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features. */ @@ -59,11 +70,29 @@ export type CreateFeatureParams = { /** * Singular and plural display names for the feature in your user interface. */ - display?: CreateFeatureDisplayRequest | undefined; + display?: CreateFeatureDisplayRequestBody | undefined; /** - * A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. + * A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. */ - creditSchema?: Array | undefined; + creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. + */ + modelMarkups?: + | { [k: string]: CreateFeatureModelMarkupsRequest } + | null + | undefined; + /** + * Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. + */ + providerMarkups?: + | { [k: string]: CreateFeatureProviderMarkupsRequest } + | null + | undefined; eventNames?: Array | undefined; /** * The ID of the feature to create. @@ -72,15 +101,16 @@ export type CreateFeatureParams = { }; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export const CreateFeatureTypeResponse = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export type CreateFeatureTypeResponse = OpenEnum< typeof CreateFeatureTypeResponse @@ -97,6 +127,16 @@ export type CreateFeatureCreditSchemaResponse = { creditCost: number; }; +export type CreateFeatureModelMarkupsResponse = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type CreateFeatureProviderMarkupsResponse = { + markup: number; +}; + /** * Display names for the feature in billing UI and customer-facing components. */ @@ -124,7 +164,7 @@ export type CreateFeatureResponse = { */ name: string; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ type: CreateFeatureTypeResponse; /** @@ -139,6 +179,24 @@ export type CreateFeatureResponse = { * For credit_system features: maps metered features to their credit costs. */ creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. + */ + modelMarkups?: + | { [k: string]: CreateFeatureModelMarkupsResponse } + | null + | undefined; + /** + * Default percentage markup for AI credit systems. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. + */ + providerMarkups?: + | { [k: string]: CreateFeatureProviderMarkupsResponse } + | null + | undefined; /** * Display names for the feature in billing UI and customer-facing components. */ @@ -150,45 +208,45 @@ export type CreateFeatureResponse = { }; /** @internal */ -export const CreateFeatureTypeRequest$outboundSchema: z.ZodMiniEnum< - typeof CreateFeatureTypeRequest -> = z.enum(CreateFeatureTypeRequest); +export const CreateFeatureTypeRequestBody$outboundSchema: z.ZodMiniEnum< + typeof CreateFeatureTypeRequestBody +> = z.enum(CreateFeatureTypeRequestBody); /** @internal */ -export type CreateFeatureDisplayRequest$Outbound = { +export type CreateFeatureDisplayRequestBody$Outbound = { singular: string; plural: string; }; /** @internal */ -export const CreateFeatureDisplayRequest$outboundSchema: z.ZodMiniType< - CreateFeatureDisplayRequest$Outbound, - CreateFeatureDisplayRequest +export const CreateFeatureDisplayRequestBody$outboundSchema: z.ZodMiniType< + CreateFeatureDisplayRequestBody$Outbound, + CreateFeatureDisplayRequestBody > = z.object({ singular: z.string(), plural: z.string(), }); -export function createFeatureDisplayRequestToJSON( - createFeatureDisplayRequest: CreateFeatureDisplayRequest, +export function createFeatureDisplayRequestBodyToJSON( + createFeatureDisplayRequestBody: CreateFeatureDisplayRequestBody, ): string { return JSON.stringify( - CreateFeatureDisplayRequest$outboundSchema.parse( - createFeatureDisplayRequest, + CreateFeatureDisplayRequestBody$outboundSchema.parse( + createFeatureDisplayRequestBody, ), ); } /** @internal */ -export type CreateFeatureCreditSchemaRequest$Outbound = { +export type CreateFeatureCreditSchemaRequestBody$Outbound = { metered_feature_id: string; credit_cost: number; }; /** @internal */ -export const CreateFeatureCreditSchemaRequest$outboundSchema: z.ZodMiniType< - CreateFeatureCreditSchemaRequest$Outbound, - CreateFeatureCreditSchemaRequest +export const CreateFeatureCreditSchemaRequestBody$outboundSchema: z.ZodMiniType< + CreateFeatureCreditSchemaRequestBody$Outbound, + CreateFeatureCreditSchemaRequestBody > = z.pipe( z.object({ meteredFeatureId: z.string(), @@ -202,12 +260,70 @@ export const CreateFeatureCreditSchemaRequest$outboundSchema: z.ZodMiniType< }), ); -export function createFeatureCreditSchemaRequestToJSON( - createFeatureCreditSchemaRequest: CreateFeatureCreditSchemaRequest, +export function createFeatureCreditSchemaRequestBodyToJSON( + createFeatureCreditSchemaRequestBody: CreateFeatureCreditSchemaRequestBody, ): string { return JSON.stringify( - CreateFeatureCreditSchemaRequest$outboundSchema.parse( - createFeatureCreditSchemaRequest, + CreateFeatureCreditSchemaRequestBody$outboundSchema.parse( + createFeatureCreditSchemaRequestBody, + ), + ); +} + +/** @internal */ +export type CreateFeatureModelMarkupsRequest$Outbound = { + markup?: number | undefined; + input_cost?: number | undefined; + output_cost?: number | undefined; +}; + +/** @internal */ +export const CreateFeatureModelMarkupsRequest$outboundSchema: z.ZodMiniType< + CreateFeatureModelMarkupsRequest$Outbound, + CreateFeatureModelMarkupsRequest +> = z.pipe( + z.object({ + markup: z.optional(z.number()), + inputCost: z.optional(z.number()), + outputCost: z.optional(z.number()), + }), + z.transform((v) => { + return remap$(v, { + inputCost: "input_cost", + outputCost: "output_cost", + }); + }), +); + +export function createFeatureModelMarkupsRequestToJSON( + createFeatureModelMarkupsRequest: CreateFeatureModelMarkupsRequest, +): string { + return JSON.stringify( + CreateFeatureModelMarkupsRequest$outboundSchema.parse( + createFeatureModelMarkupsRequest, + ), + ); +} + +/** @internal */ +export type CreateFeatureProviderMarkupsRequest$Outbound = { + markup: number; +}; + +/** @internal */ +export const CreateFeatureProviderMarkupsRequest$outboundSchema: z.ZodMiniType< + CreateFeatureProviderMarkupsRequest$Outbound, + CreateFeatureProviderMarkupsRequest +> = z.object({ + markup: z.number(), +}); + +export function createFeatureProviderMarkupsRequestToJSON( + createFeatureProviderMarkupsRequest: CreateFeatureProviderMarkupsRequest, +): string { + return JSON.stringify( + CreateFeatureProviderMarkupsRequest$outboundSchema.parse( + createFeatureProviderMarkupsRequest, ), ); } @@ -217,8 +333,19 @@ export type CreateFeatureParams$Outbound = { name: string; type: string; consumable?: boolean | undefined; - display?: CreateFeatureDisplayRequest$Outbound | undefined; - credit_schema?: Array | undefined; + display?: CreateFeatureDisplayRequestBody$Outbound | undefined; + credit_schema?: + | Array + | undefined; + model_markups?: + | { [k: string]: CreateFeatureModelMarkupsRequest$Outbound } + | null + | undefined; + default_markup?: number | undefined; + provider_markups?: + | { [k: string]: CreateFeatureProviderMarkupsRequest$Outbound } + | null + | undefined; event_names?: Array | undefined; feature_id: string; }; @@ -230,13 +357,28 @@ export const CreateFeatureParams$outboundSchema: z.ZodMiniType< > = z.pipe( z.object({ name: z.string(), - type: CreateFeatureTypeRequest$outboundSchema, + type: CreateFeatureTypeRequestBody$outboundSchema, consumable: z.optional(z.boolean()), display: z.optional( - z.lazy(() => CreateFeatureDisplayRequest$outboundSchema), + z.lazy(() => CreateFeatureDisplayRequestBody$outboundSchema), ), creditSchema: z.optional( - z.array(z.lazy(() => CreateFeatureCreditSchemaRequest$outboundSchema)), + z.array( + z.lazy(() => CreateFeatureCreditSchemaRequestBody$outboundSchema), + ), + ), + modelMarkups: z.optional( + z.nullable(z.record( + z.string(), + z.lazy(() => CreateFeatureModelMarkupsRequest$outboundSchema), + )), + ), + defaultMarkup: z.optional(z.number()), + providerMarkups: z.optional( + z.nullable(z.record( + z.string(), + z.lazy(() => CreateFeatureProviderMarkupsRequest$outboundSchema), + )), ), eventNames: z.optional(z.array(z.string())), featureId: z.string(), @@ -244,6 +386,9 @@ export const CreateFeatureParams$outboundSchema: z.ZodMiniType< z.transform((v) => { return remap$(v, { creditSchema: "credit_schema", + modelMarkups: "model_markups", + defaultMarkup: "default_markup", + providerMarkups: "provider_markups", eventNames: "event_names", featureId: "feature_id", }); @@ -291,6 +436,53 @@ export function createFeatureCreditSchemaResponseFromJSON( ); } +/** @internal */ +export const CreateFeatureModelMarkupsResponse$inboundSchema: z.ZodMiniType< + CreateFeatureModelMarkupsResponse, + unknown +> = z.pipe( + z.object({ + markup: types.optional(types.number()), + input_cost: types.optional(types.number()), + output_cost: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "input_cost": "inputCost", + "output_cost": "outputCost", + }); + }), +); + +export function createFeatureModelMarkupsResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => CreateFeatureModelMarkupsResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CreateFeatureModelMarkupsResponse' from JSON`, + ); +} + +/** @internal */ +export const CreateFeatureProviderMarkupsResponse$inboundSchema: z.ZodMiniType< + CreateFeatureProviderMarkupsResponse, + unknown +> = z.object({ + markup: types.number(), +}); + +export function createFeatureProviderMarkupsResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => + CreateFeatureProviderMarkupsResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CreateFeatureProviderMarkupsResponse' from JSON`, + ); +} + /** @internal */ export const CreateFeatureDisplayResponse$inboundSchema: z.ZodMiniType< CreateFeatureDisplayResponse, @@ -324,15 +516,27 @@ export const CreateFeatureResponse$inboundSchema: z.ZodMiniType< credit_schema: types.optional( z.array(z.lazy(() => CreateFeatureCreditSchemaResponse$inboundSchema)), ), - display: types.optional( - z.lazy(() => CreateFeatureDisplayResponse$inboundSchema), - ), + model_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => CreateFeatureModelMarkupsResponse$inboundSchema), + ))), + default_markup: types.optional(types.number()), + provider_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => CreateFeatureProviderMarkupsResponse$inboundSchema), + ))), + display: types.optional(z.lazy(() => + CreateFeatureDisplayResponse$inboundSchema + )), archived: types.boolean(), }), z.transform((v) => { return remap$(v, { "event_names": "eventNames", "credit_schema": "creditSchema", + "model_markups": "modelMarkups", + "default_markup": "defaultMarkup", + "provider_markups": "providerMarkups", }); }), ); diff --git a/packages/sdk/src/models/create-plan-op.ts b/packages/sdk/src/models/create-plan-op.ts index 55813fba8..67bb2006c 100644 --- a/packages/sdk/src/models/create-plan-op.ts +++ b/packages/sdk/src/models/create-plan-op.ts @@ -19,7 +19,7 @@ export type CreatePlanGlobals = { /** * Billing interval (e.g. 'month', 'year'). */ -export const CreatePlanPriceIntervalRequest = { +export const CreatePlanPriceIntervalRequestBody = { OneOff: "one_off", Week: "week", Month: "month", @@ -30,14 +30,14 @@ export const CreatePlanPriceIntervalRequest = { /** * Billing interval (e.g. 'month', 'year'). */ -export type CreatePlanPriceIntervalRequest = ClosedEnum< - typeof CreatePlanPriceIntervalRequest +export type CreatePlanPriceIntervalRequestBody = ClosedEnum< + typeof CreatePlanPriceIntervalRequestBody >; /** * Base recurring price for the plan. Omit for free or usage-only plans. */ -export type CreatePlanPriceRequest = { +export type CreatePlanPriceRequestBody = { /** * Base price amount for the plan. */ @@ -45,7 +45,7 @@ export type CreatePlanPriceRequest = { /** * Billing interval (e.g. 'month', 'year'). */ - interval: CreatePlanPriceIntervalRequest; + interval: CreatePlanPriceIntervalRequestBody; /** * Number of intervals per billing cycle. Defaults to 1. */ @@ -55,7 +55,7 @@ export type CreatePlanPriceRequest = { /** * Interval at which balance resets (e.g. 'month', 'year'). For consumable features only. */ -export const CreatePlanResetIntervalRequest = { +export const CreatePlanResetIntervalRequestBody = { OneOff: "one_off", Minute: "minute", Hour: "hour", @@ -69,44 +69,44 @@ export const CreatePlanResetIntervalRequest = { /** * Interval at which balance resets (e.g. 'month', 'year'). For consumable features only. */ -export type CreatePlanResetIntervalRequest = ClosedEnum< - typeof CreatePlanResetIntervalRequest +export type CreatePlanResetIntervalRequestBody = ClosedEnum< + typeof CreatePlanResetIntervalRequestBody >; /** * Reset configuration for consumable features. Omit for non-consumable features like seats. */ -export type CreatePlanResetRequest = { +export type CreatePlanResetRequestBody = { /** * Interval at which balance resets (e.g. 'month', 'year'). For consumable features only. */ - interval: CreatePlanResetIntervalRequest; + interval: CreatePlanResetIntervalRequestBody; /** * Number of intervals between resets. Defaults to 1. */ intervalCount?: number | undefined; }; -export type CreatePlanTo = number | string; +export type CreatePlanToRequestBody = number | string; -export type CreatePlanTier = { +export type CreatePlanTierRequestBody = { to: number | string; amount?: number | undefined; flatAmount?: number | undefined; }; -export const CreatePlanTierBehaviorRequest = { +export const CreatePlanTierBehaviorRequestBody = { Graduated: "graduated", Volume: "volume", } as const; -export type CreatePlanTierBehaviorRequest = ClosedEnum< - typeof CreatePlanTierBehaviorRequest +export type CreatePlanTierBehaviorRequestBody = ClosedEnum< + typeof CreatePlanTierBehaviorRequestBody >; /** * Billing interval. For consumable features, should match reset.interval. */ -export const CreatePlanItemPriceIntervalRequest = { +export const CreatePlanItemPriceIntervalRequestBody = { OneOff: "one_off", Week: "week", Month: "month", @@ -117,28 +117,28 @@ export const CreatePlanItemPriceIntervalRequest = { /** * Billing interval. For consumable features, should match reset.interval. */ -export type CreatePlanItemPriceIntervalRequest = ClosedEnum< - typeof CreatePlanItemPriceIntervalRequest +export type CreatePlanItemPriceIntervalRequestBody = ClosedEnum< + typeof CreatePlanItemPriceIntervalRequestBody >; /** * 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. */ -export const CreatePlanBillingMethodRequest = { +export const CreatePlanBillingMethodRequestBody = { Prepaid: "prepaid", UsageBased: "usage_based", } as const; /** * 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. */ -export type CreatePlanBillingMethodRequest = ClosedEnum< - typeof CreatePlanBillingMethodRequest +export type CreatePlanBillingMethodRequestBody = ClosedEnum< + typeof CreatePlanBillingMethodRequestBody >; /** * Pricing for usage beyond included units. Omit for free features. */ -export type CreatePlanItemPriceRequest = { +export type CreatePlanItemPriceRequestBody = { /** * Price per billing_units after included usage. Either 'amount' or 'tiers' is required. */ @@ -146,12 +146,12 @@ export type CreatePlanItemPriceRequest = { /** * Tiered pricing. Either 'amount' or 'tiers' is required. */ - tiers?: Array | undefined; - tierBehavior?: CreatePlanTierBehaviorRequest | undefined; + tiers?: Array | undefined; + tierBehavior?: CreatePlanTierBehaviorRequestBody | undefined; /** * Billing interval. For consumable features, should match reset.interval. */ - interval: CreatePlanItemPriceIntervalRequest; + interval: CreatePlanItemPriceIntervalRequestBody; /** * Number of intervals per billing cycle. Defaults to 1. */ @@ -163,11 +163,11 @@ export type CreatePlanItemPriceRequest = { /** * 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. */ - billingMethod: CreatePlanBillingMethodRequest; + billingMethod: CreatePlanBillingMethodRequestBody; /** - * 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. */ - maxPurchase?: number | undefined; + maxPurchase?: number | null | undefined; }; /** @@ -216,21 +216,21 @@ export type CreatePlanProration = { /** * When rolled over units expire. */ -export const CreatePlanExpiryDurationTypeRequest = { +export const CreatePlanExpiryDurationTypeRequestBody = { Month: "month", Forever: "forever", } as const; /** * When rolled over units expire. */ -export type CreatePlanExpiryDurationTypeRequest = ClosedEnum< - typeof CreatePlanExpiryDurationTypeRequest +export type CreatePlanExpiryDurationTypeRequestBody = ClosedEnum< + typeof CreatePlanExpiryDurationTypeRequestBody >; /** * Rollover config for unused units. If set, unused included units carry over. */ -export type CreatePlanRolloverRequest = { +export type CreatePlanRolloverRequestBody = { /** * Max rollover units. Omit for unlimited rollover. */ @@ -242,7 +242,7 @@ export type CreatePlanRolloverRequest = { /** * When rolled over units expire. */ - expiryDurationType: CreatePlanExpiryDurationTypeRequest; + expiryDurationType: CreatePlanExpiryDurationTypeRequestBody; /** * Number of periods before expiry. */ @@ -268,11 +268,11 @@ export type CreatePlanPlanItem = { /** * Reset configuration for consumable features. Omit for non-consumable features like seats. */ - reset?: CreatePlanResetRequest | undefined; + reset?: CreatePlanResetRequestBody | undefined; /** * Pricing for usage beyond included units. Omit for free features. */ - price?: CreatePlanItemPriceRequest | undefined; + price?: CreatePlanItemPriceRequestBody | undefined; /** * Proration settings for prepaid features. Controls mid-cycle quantity change billing. */ @@ -280,7 +280,7 @@ export type CreatePlanPlanItem = { /** * Rollover config for unused units. If set, unused included units carry over. */ - rollover?: CreatePlanRolloverRequest | undefined; + rollover?: CreatePlanRolloverRequestBody | undefined; }; /** @@ -370,7 +370,7 @@ export type CreatePlanParams = { /** * Base recurring price for the plan. Omit for free or usage-only plans. */ - price?: CreatePlanPriceRequest | undefined; + price?: CreatePlanPriceRequestBody | undefined; /** * Feature configurations for this plan. Each item defines included units, pricing, and reset behavior. */ @@ -446,6 +446,7 @@ export const CreatePlanType = { SingleUse: "single_use", ContinuousUse: "continuous_use", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** * The type of the feature @@ -536,6 +537,14 @@ export type CreatePlanResetResponse = { intervalCount?: number | undefined; }; +export type CreatePlanToResponse = number | string; + +export type CreatePlanTierResponse = { + to: number | string; + amount: number; + flatAmount?: number | undefined; +}; + export const CreatePlanTierBehaviorResponse = { Graduated: "graduated", Volume: "volume", @@ -584,7 +593,7 @@ export type CreatePlanItemPriceResponse = { /** * Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. */ - tiers?: Array | undefined; + tiers?: Array | undefined; tierBehavior?: CreatePlanTierBehaviorResponse | undefined; /** * Billing interval for this price. For consumable features, should match reset.interval. @@ -876,25 +885,25 @@ export type CreatePlanResponse = { }; /** @internal */ -export const CreatePlanPriceIntervalRequest$outboundSchema: z.ZodMiniEnum< - typeof CreatePlanPriceIntervalRequest -> = z.enum(CreatePlanPriceIntervalRequest); +export const CreatePlanPriceIntervalRequestBody$outboundSchema: z.ZodMiniEnum< + typeof CreatePlanPriceIntervalRequestBody +> = z.enum(CreatePlanPriceIntervalRequestBody); /** @internal */ -export type CreatePlanPriceRequest$Outbound = { +export type CreatePlanPriceRequestBody$Outbound = { amount: number; interval: string; interval_count?: number | undefined; }; /** @internal */ -export const CreatePlanPriceRequest$outboundSchema: z.ZodMiniType< - CreatePlanPriceRequest$Outbound, - CreatePlanPriceRequest +export const CreatePlanPriceRequestBody$outboundSchema: z.ZodMiniType< + CreatePlanPriceRequestBody$Outbound, + CreatePlanPriceRequestBody > = z.pipe( z.object({ amount: z.number(), - interval: CreatePlanPriceIntervalRequest$outboundSchema, + interval: CreatePlanPriceIntervalRequestBody$outboundSchema, intervalCount: z.optional(z.number()), }), z.transform((v) => { @@ -904,32 +913,32 @@ export const CreatePlanPriceRequest$outboundSchema: z.ZodMiniType< }), ); -export function createPlanPriceRequestToJSON( - createPlanPriceRequest: CreatePlanPriceRequest, +export function createPlanPriceRequestBodyToJSON( + createPlanPriceRequestBody: CreatePlanPriceRequestBody, ): string { return JSON.stringify( - CreatePlanPriceRequest$outboundSchema.parse(createPlanPriceRequest), + CreatePlanPriceRequestBody$outboundSchema.parse(createPlanPriceRequestBody), ); } /** @internal */ -export const CreatePlanResetIntervalRequest$outboundSchema: z.ZodMiniEnum< - typeof CreatePlanResetIntervalRequest -> = z.enum(CreatePlanResetIntervalRequest); +export const CreatePlanResetIntervalRequestBody$outboundSchema: z.ZodMiniEnum< + typeof CreatePlanResetIntervalRequestBody +> = z.enum(CreatePlanResetIntervalRequestBody); /** @internal */ -export type CreatePlanResetRequest$Outbound = { +export type CreatePlanResetRequestBody$Outbound = { interval: string; interval_count?: number | undefined; }; /** @internal */ -export const CreatePlanResetRequest$outboundSchema: z.ZodMiniType< - CreatePlanResetRequest$Outbound, - CreatePlanResetRequest +export const CreatePlanResetRequestBody$outboundSchema: z.ZodMiniType< + CreatePlanResetRequestBody$Outbound, + CreatePlanResetRequestBody > = z.pipe( z.object({ - interval: CreatePlanResetIntervalRequest$outboundSchema, + interval: CreatePlanResetIntervalRequestBody$outboundSchema, intervalCount: z.optional(z.number()), }), z.transform((v) => { @@ -939,38 +948,42 @@ export const CreatePlanResetRequest$outboundSchema: z.ZodMiniType< }), ); -export function createPlanResetRequestToJSON( - createPlanResetRequest: CreatePlanResetRequest, +export function createPlanResetRequestBodyToJSON( + createPlanResetRequestBody: CreatePlanResetRequestBody, ): string { return JSON.stringify( - CreatePlanResetRequest$outboundSchema.parse(createPlanResetRequest), + CreatePlanResetRequestBody$outboundSchema.parse(createPlanResetRequestBody), ); } /** @internal */ -export type CreatePlanTo$Outbound = number | string; +export type CreatePlanToRequestBody$Outbound = number | string; /** @internal */ -export const CreatePlanTo$outboundSchema: z.ZodMiniType< - CreatePlanTo$Outbound, - CreatePlanTo +export const CreatePlanToRequestBody$outboundSchema: z.ZodMiniType< + CreatePlanToRequestBody$Outbound, + CreatePlanToRequestBody > = smartUnion([z.number(), z.string()]); -export function createPlanToToJSON(createPlanTo: CreatePlanTo): string { - return JSON.stringify(CreatePlanTo$outboundSchema.parse(createPlanTo)); +export function createPlanToRequestBodyToJSON( + createPlanToRequestBody: CreatePlanToRequestBody, +): string { + return JSON.stringify( + CreatePlanToRequestBody$outboundSchema.parse(createPlanToRequestBody), + ); } /** @internal */ -export type CreatePlanTier$Outbound = { +export type CreatePlanTierRequestBody$Outbound = { to: number | string; amount?: number | undefined; flat_amount?: number | undefined; }; /** @internal */ -export const CreatePlanTier$outboundSchema: z.ZodMiniType< - CreatePlanTier$Outbound, - CreatePlanTier +export const CreatePlanTierRequestBody$outboundSchema: z.ZodMiniType< + CreatePlanTierRequestBody$Outbound, + CreatePlanTierRequestBody > = z.pipe( z.object({ to: smartUnion([z.number(), z.string()]), @@ -984,51 +997,58 @@ export const CreatePlanTier$outboundSchema: z.ZodMiniType< }), ); -export function createPlanTierToJSON(createPlanTier: CreatePlanTier): string { - return JSON.stringify(CreatePlanTier$outboundSchema.parse(createPlanTier)); +export function createPlanTierRequestBodyToJSON( + createPlanTierRequestBody: CreatePlanTierRequestBody, +): string { + return JSON.stringify( + CreatePlanTierRequestBody$outboundSchema.parse(createPlanTierRequestBody), + ); } /** @internal */ -export const CreatePlanTierBehaviorRequest$outboundSchema: z.ZodMiniEnum< - typeof CreatePlanTierBehaviorRequest -> = z.enum(CreatePlanTierBehaviorRequest); +export const CreatePlanTierBehaviorRequestBody$outboundSchema: z.ZodMiniEnum< + typeof CreatePlanTierBehaviorRequestBody +> = z.enum(CreatePlanTierBehaviorRequestBody); /** @internal */ -export const CreatePlanItemPriceIntervalRequest$outboundSchema: z.ZodMiniEnum< - typeof CreatePlanItemPriceIntervalRequest -> = z.enum(CreatePlanItemPriceIntervalRequest); +export const CreatePlanItemPriceIntervalRequestBody$outboundSchema: + z.ZodMiniEnum = z.enum( + CreatePlanItemPriceIntervalRequestBody, + ); /** @internal */ -export const CreatePlanBillingMethodRequest$outboundSchema: z.ZodMiniEnum< - typeof CreatePlanBillingMethodRequest -> = z.enum(CreatePlanBillingMethodRequest); +export const CreatePlanBillingMethodRequestBody$outboundSchema: z.ZodMiniEnum< + typeof CreatePlanBillingMethodRequestBody +> = z.enum(CreatePlanBillingMethodRequestBody); /** @internal */ -export type CreatePlanItemPriceRequest$Outbound = { +export type CreatePlanItemPriceRequestBody$Outbound = { amount?: number | undefined; - tiers?: Array | undefined; + tiers?: Array | undefined; tier_behavior?: string | undefined; interval: string; interval_count: number; billing_units: number; billing_method: string; - max_purchase?: number | undefined; + max_purchase?: number | null | undefined; }; /** @internal */ -export const CreatePlanItemPriceRequest$outboundSchema: z.ZodMiniType< - CreatePlanItemPriceRequest$Outbound, - CreatePlanItemPriceRequest +export const CreatePlanItemPriceRequestBody$outboundSchema: z.ZodMiniType< + CreatePlanItemPriceRequestBody$Outbound, + CreatePlanItemPriceRequestBody > = z.pipe( z.object({ amount: z.optional(z.number()), - tiers: z.optional(z.array(z.lazy(() => CreatePlanTier$outboundSchema))), - tierBehavior: z.optional(CreatePlanTierBehaviorRequest$outboundSchema), - interval: CreatePlanItemPriceIntervalRequest$outboundSchema, + tiers: z.optional( + z.array(z.lazy(() => CreatePlanTierRequestBody$outboundSchema)), + ), + tierBehavior: z.optional(CreatePlanTierBehaviorRequestBody$outboundSchema), + interval: CreatePlanItemPriceIntervalRequestBody$outboundSchema, intervalCount: z._default(z.number(), 1), billingUnits: z._default(z.number(), 1), - billingMethod: CreatePlanBillingMethodRequest$outboundSchema, - maxPurchase: z.optional(z.number()), + billingMethod: CreatePlanBillingMethodRequestBody$outboundSchema, + maxPurchase: z.optional(z.nullable(z.number())), }), z.transform((v) => { return remap$(v, { @@ -1041,11 +1061,13 @@ export const CreatePlanItemPriceRequest$outboundSchema: z.ZodMiniType< }), ); -export function createPlanItemPriceRequestToJSON( - createPlanItemPriceRequest: CreatePlanItemPriceRequest, +export function createPlanItemPriceRequestBodyToJSON( + createPlanItemPriceRequestBody: CreatePlanItemPriceRequestBody, ): string { return JSON.stringify( - CreatePlanItemPriceRequest$outboundSchema.parse(createPlanItemPriceRequest), + CreatePlanItemPriceRequestBody$outboundSchema.parse( + createPlanItemPriceRequestBody, + ), ); } @@ -1091,12 +1113,13 @@ export function createPlanProrationToJSON( } /** @internal */ -export const CreatePlanExpiryDurationTypeRequest$outboundSchema: z.ZodMiniEnum< - typeof CreatePlanExpiryDurationTypeRequest -> = z.enum(CreatePlanExpiryDurationTypeRequest); +export const CreatePlanExpiryDurationTypeRequestBody$outboundSchema: + z.ZodMiniEnum = z.enum( + CreatePlanExpiryDurationTypeRequestBody, + ); /** @internal */ -export type CreatePlanRolloverRequest$Outbound = { +export type CreatePlanRolloverRequestBody$Outbound = { max?: number | undefined; max_percentage?: number | undefined; expiry_duration_type: string; @@ -1104,14 +1127,14 @@ export type CreatePlanRolloverRequest$Outbound = { }; /** @internal */ -export const CreatePlanRolloverRequest$outboundSchema: z.ZodMiniType< - CreatePlanRolloverRequest$Outbound, - CreatePlanRolloverRequest +export const CreatePlanRolloverRequestBody$outboundSchema: z.ZodMiniType< + CreatePlanRolloverRequestBody$Outbound, + CreatePlanRolloverRequestBody > = z.pipe( z.object({ max: z.optional(z.number()), maxPercentage: z.optional(z.number()), - expiryDurationType: CreatePlanExpiryDurationTypeRequest$outboundSchema, + expiryDurationType: CreatePlanExpiryDurationTypeRequestBody$outboundSchema, expiryDurationLength: z.optional(z.number()), }), z.transform((v) => { @@ -1123,11 +1146,13 @@ export const CreatePlanRolloverRequest$outboundSchema: z.ZodMiniType< }), ); -export function createPlanRolloverRequestToJSON( - createPlanRolloverRequest: CreatePlanRolloverRequest, +export function createPlanRolloverRequestBodyToJSON( + createPlanRolloverRequestBody: CreatePlanRolloverRequestBody, ): string { return JSON.stringify( - CreatePlanRolloverRequest$outboundSchema.parse(createPlanRolloverRequest), + CreatePlanRolloverRequestBody$outboundSchema.parse( + createPlanRolloverRequestBody, + ), ); } @@ -1136,10 +1161,10 @@ export type CreatePlanPlanItem$Outbound = { feature_id: string; included?: number | undefined; unlimited?: boolean | undefined; - reset?: CreatePlanResetRequest$Outbound | undefined; - price?: CreatePlanItemPriceRequest$Outbound | undefined; + reset?: CreatePlanResetRequestBody$Outbound | undefined; + price?: CreatePlanItemPriceRequestBody$Outbound | undefined; proration?: CreatePlanProration$Outbound | undefined; - rollover?: CreatePlanRolloverRequest$Outbound | undefined; + rollover?: CreatePlanRolloverRequestBody$Outbound | undefined; }; /** @internal */ @@ -1151,11 +1176,13 @@ export const CreatePlanPlanItem$outboundSchema: z.ZodMiniType< featureId: z.string(), included: z.optional(z.number()), unlimited: z.optional(z.boolean()), - reset: z.optional(z.lazy(() => CreatePlanResetRequest$outboundSchema)), - price: z.optional(z.lazy(() => CreatePlanItemPriceRequest$outboundSchema)), + reset: z.optional(z.lazy(() => CreatePlanResetRequestBody$outboundSchema)), + price: z.optional( + z.lazy(() => CreatePlanItemPriceRequestBody$outboundSchema), + ), proration: z.optional(z.lazy(() => CreatePlanProration$outboundSchema)), rollover: z.optional( - z.lazy(() => CreatePlanRolloverRequest$outboundSchema), + z.lazy(() => CreatePlanRolloverRequestBody$outboundSchema), ), }), z.transform((v) => { @@ -1259,7 +1286,7 @@ export type CreatePlanParams$Outbound = { description?: string | null | undefined; add_on: boolean; auto_enable: boolean; - price?: CreatePlanPriceRequest$Outbound | undefined; + price?: CreatePlanPriceRequestBody$Outbound | undefined; items?: Array | undefined; free_trial?: FreeTrialRequest$Outbound | undefined; config?: CreatePlanConfigRequest$Outbound | undefined; @@ -1278,7 +1305,7 @@ export const CreatePlanParams$outboundSchema: z.ZodMiniType< description: z.optional(z.nullable(z.string())), addOn: z._default(z.boolean(), false), autoEnable: z._default(z.boolean(), false), - price: z.optional(z.lazy(() => CreatePlanPriceRequest$outboundSchema)), + price: z.optional(z.lazy(() => CreatePlanPriceRequestBody$outboundSchema)), items: z.optional(z.array(z.lazy(() => CreatePlanPlanItem$outboundSchema))), freeTrial: z.optional(z.lazy(() => FreeTrialRequest$outboundSchema)), config: z.optional(z.lazy(() => CreatePlanConfigRequest$outboundSchema)), @@ -1482,6 +1509,49 @@ export function createPlanResetResponseFromJSON( ); } +/** @internal */ +export const CreatePlanToResponse$inboundSchema: z.ZodMiniType< + CreatePlanToResponse, + unknown +> = smartUnion([types.number(), types.string()]); + +export function createPlanToResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => CreatePlanToResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CreatePlanToResponse' from JSON`, + ); +} + +/** @internal */ +export const CreatePlanTierResponse$inboundSchema: z.ZodMiniType< + CreatePlanTierResponse, + unknown +> = z.pipe( + z.object({ + to: smartUnion([types.number(), types.string()]), + amount: types.number(), + flat_amount: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "flat_amount": "flatAmount", + }); + }), +); + +export function createPlanTierResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => CreatePlanTierResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CreatePlanTierResponse' from JSON`, + ); +} + /** @internal */ export const CreatePlanTierBehaviorResponse$inboundSchema: z.ZodMiniType< CreatePlanTierBehaviorResponse, @@ -1507,7 +1577,9 @@ export const CreatePlanItemPriceResponse$inboundSchema: z.ZodMiniType< > = z.pipe( z.object({ amount: types.optional(types.number()), - tiers: types.optional(z.array(types.nullable(z.any()))), + tiers: types.optional( + z.array(z.lazy(() => CreatePlanTierResponse$inboundSchema)), + ), tier_behavior: types.optional(CreatePlanTierBehaviorResponse$inboundSchema), interval: CreatePlanPriceItemIntervalResponse$inboundSchema, interval_count: types.optional(types.number()), diff --git a/packages/sdk/src/models/create-schedule-op.ts b/packages/sdk/src/models/create-schedule-op.ts index 23921226d..62fd84ac7 100644 --- a/packages/sdk/src/models/create-schedule-op.ts +++ b/packages/sdk/src/models/create-schedule-op.ts @@ -9,6 +9,7 @@ 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 { smartUnion } from "../types/smart-union.js"; import { SDKValidationError } from "./sdk-validation-error.js"; export type CreateScheduleGlobals = { @@ -139,7 +140,7 @@ export type CreateScheduleBasePrice2 = { /** * Interval at which balance resets (e.g. 'month', 'year'). For consumable features only. */ -export const CreateScheduleResetInterval2 = { +export const CreateScheduleItemResetInterval2 = { OneOff: "one_off", Minute: "minute", Hour: "hour", @@ -153,36 +154,36 @@ export const CreateScheduleResetInterval2 = { /** * Interval at which balance resets (e.g. 'month', 'year'). For consumable features only. */ -export type CreateScheduleResetInterval2 = ClosedEnum< - typeof CreateScheduleResetInterval2 +export type CreateScheduleItemResetInterval2 = ClosedEnum< + typeof CreateScheduleItemResetInterval2 >; /** * Reset configuration for consumable features. Omit for non-consumable features like seats. */ -export type CreateScheduleReset2 = { +export type CreateScheduleItemReset2 = { /** * Interval at which balance resets (e.g. 'month', 'year'). For consumable features only. */ - interval: CreateScheduleResetInterval2; + interval: CreateScheduleItemResetInterval2; /** * Number of intervals between resets. Defaults to 1. */ intervalCount?: number | undefined; }; -export type CreateScheduleTier2 = { +export type CreateScheduleItemTier2 = { to?: any | undefined; amount?: any | undefined; flatAmount?: any | undefined; }; -export const CreateScheduleTierBehavior2 = { +export const CreateScheduleItemTierBehavior2 = { Graduated: "graduated", Volume: "volume", } as const; -export type CreateScheduleTierBehavior2 = ClosedEnum< - typeof CreateScheduleTierBehavior2 +export type CreateScheduleItemTierBehavior2 = ClosedEnum< + typeof CreateScheduleItemTierBehavior2 >; /** @@ -206,21 +207,21 @@ export type CreateScheduleItemPriceInterval2 = ClosedEnum< /** * 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. */ -export const CreateScheduleBillingMethod2 = { +export const CreateScheduleItemBillingMethod2 = { Prepaid: "prepaid", UsageBased: "usage_based", } as const; /** * 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. */ -export type CreateScheduleBillingMethod2 = ClosedEnum< - typeof CreateScheduleBillingMethod2 +export type CreateScheduleItemBillingMethod2 = ClosedEnum< + typeof CreateScheduleItemBillingMethod2 >; /** * Pricing for usage beyond included units. Omit for free features. */ -export type CreateSchedulePrice2 = { +export type CreateScheduleItemPrice2 = { /** * Price per billing_units after included usage. Either 'amount' or 'tiers' is required. */ @@ -228,8 +229,8 @@ export type CreateSchedulePrice2 = { /** * Tiered pricing. Either 'amount' or 'tiers' is required. */ - tiers?: Array | undefined; - tierBehavior?: CreateScheduleTierBehavior2 | undefined; + tiers?: Array | undefined; + tierBehavior?: CreateScheduleItemTierBehavior2 | undefined; /** * Billing interval. For consumable features, should match reset.interval. */ @@ -245,17 +246,17 @@ export type CreateSchedulePrice2 = { /** * 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. */ - billingMethod: CreateScheduleBillingMethod2; + billingMethod: CreateScheduleItemBillingMethod2; /** - * 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. */ - maxPurchase?: number | undefined; + maxPurchase?: number | null | undefined; }; /** * Billing behavior when quantity increases mid-cycle. */ -export const CreateScheduleOnIncrease2 = { +export const CreateScheduleItemOnIncrease2 = { BillImmediately: "bill_immediately", ProrateImmediately: "prorate_immediately", ProrateNextCycle: "prorate_next_cycle", @@ -264,14 +265,14 @@ export const CreateScheduleOnIncrease2 = { /** * Billing behavior when quantity increases mid-cycle. */ -export type CreateScheduleOnIncrease2 = ClosedEnum< - typeof CreateScheduleOnIncrease2 +export type CreateScheduleItemOnIncrease2 = ClosedEnum< + typeof CreateScheduleItemOnIncrease2 >; /** * Credit behavior when quantity decreases mid-cycle. */ -export const CreateScheduleOnDecrease2 = { +export const CreateScheduleItemOnDecrease2 = { Prorate: "prorate", ProrateImmediately: "prorate_immediately", ProrateNextCycle: "prorate_next_cycle", @@ -281,42 +282,42 @@ export const CreateScheduleOnDecrease2 = { /** * Credit behavior when quantity decreases mid-cycle. */ -export type CreateScheduleOnDecrease2 = ClosedEnum< - typeof CreateScheduleOnDecrease2 +export type CreateScheduleItemOnDecrease2 = ClosedEnum< + typeof CreateScheduleItemOnDecrease2 >; /** * Proration settings for prepaid features. Controls mid-cycle quantity change billing. */ -export type CreateScheduleProration2 = { +export type CreateScheduleItemProration2 = { /** * Billing behavior when quantity increases mid-cycle. */ - onIncrease: CreateScheduleOnIncrease2; + onIncrease: CreateScheduleItemOnIncrease2; /** * Credit behavior when quantity decreases mid-cycle. */ - onDecrease: CreateScheduleOnDecrease2; + onDecrease: CreateScheduleItemOnDecrease2; }; /** * When rolled over units expire. */ -export const CreateScheduleExpiryDurationType2 = { +export const CreateScheduleItemExpiryDurationType2 = { Month: "month", Forever: "forever", } as const; /** * When rolled over units expire. */ -export type CreateScheduleExpiryDurationType2 = ClosedEnum< - typeof CreateScheduleExpiryDurationType2 +export type CreateScheduleItemExpiryDurationType2 = ClosedEnum< + typeof CreateScheduleItemExpiryDurationType2 >; /** * Rollover config for unused units. If set, unused included units carry over. */ -export type CreateScheduleRollover2 = { +export type CreateScheduleItemRollover2 = { /** * Max rollover units. Omit for unlimited rollover. */ @@ -328,7 +329,7 @@ export type CreateScheduleRollover2 = { /** * When rolled over units expire. */ - expiryDurationType: CreateScheduleExpiryDurationType2; + expiryDurationType: CreateScheduleItemExpiryDurationType2; /** * Number of periods before expiry. */ @@ -338,7 +339,7 @@ export type CreateScheduleRollover2 = { /** * Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. */ -export type CreateSchedulePlanItem2 = { +export type CreateScheduleItemPlanItem2 = { /** * The ID of the feature to configure. */ @@ -354,23 +355,329 @@ export type CreateSchedulePlanItem2 = { /** * Reset configuration for consumable features. Omit for non-consumable features like seats. */ - reset?: CreateScheduleReset2 | undefined; + reset?: CreateScheduleItemReset2 | undefined; /** * Pricing for usage beyond included units. Omit for free features. */ - price?: CreateSchedulePrice2 | undefined; + price?: CreateScheduleItemPrice2 | undefined; /** * Proration settings for prepaid features. Controls mid-cycle quantity change billing. */ - proration?: CreateScheduleProration2 | undefined; + proration?: CreateScheduleItemProration2 | undefined; /** * Rollover config for unused units. If set, unused included units carry over. */ - rollover?: CreateScheduleRollover2 | undefined; + rollover?: CreateScheduleItemRollover2 | undefined; }; /** - * Customize the plan to schedule. Can override the price, items, or both. + * Interval at which balance resets (e.g. 'month', 'year'). For consumable features only. + */ +export const CreateScheduleAddItemResetInterval2 = { + OneOff: "one_off", + Minute: "minute", + Hour: "hour", + Day: "day", + Week: "week", + Month: "month", + Quarter: "quarter", + SemiAnnual: "semi_annual", + Year: "year", +} as const; +/** + * Interval at which balance resets (e.g. 'month', 'year'). For consumable features only. + */ +export type CreateScheduleAddItemResetInterval2 = ClosedEnum< + typeof CreateScheduleAddItemResetInterval2 +>; + +/** + * Reset configuration for consumable features. Omit for non-consumable features like seats. + */ +export type CreateScheduleAddItemReset2 = { + /** + * Interval at which balance resets (e.g. 'month', 'year'). For consumable features only. + */ + interval: CreateScheduleAddItemResetInterval2; + /** + * Number of intervals between resets. Defaults to 1. + */ + intervalCount?: number | undefined; +}; + +export type CreateScheduleAddItemTier2 = { + to?: any | undefined; + amount?: any | undefined; + flatAmount?: any | undefined; +}; + +export const CreateScheduleAddItemTierBehavior2 = { + Graduated: "graduated", + Volume: "volume", +} as const; +export type CreateScheduleAddItemTierBehavior2 = ClosedEnum< + typeof CreateScheduleAddItemTierBehavior2 +>; + +/** + * Billing interval. For consumable features, should match reset.interval. + */ +export const CreateScheduleAddItemPriceInterval2 = { + OneOff: "one_off", + Week: "week", + Month: "month", + Quarter: "quarter", + SemiAnnual: "semi_annual", + Year: "year", +} as const; +/** + * Billing interval. For consumable features, should match reset.interval. + */ +export type CreateScheduleAddItemPriceInterval2 = ClosedEnum< + typeof CreateScheduleAddItemPriceInterval2 +>; + +/** + * 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. + */ +export const CreateScheduleAddItemBillingMethod2 = { + Prepaid: "prepaid", + UsageBased: "usage_based", +} as const; +/** + * 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. + */ +export type CreateScheduleAddItemBillingMethod2 = ClosedEnum< + typeof CreateScheduleAddItemBillingMethod2 +>; + +/** + * Pricing for usage beyond included units. Omit for free features. + */ +export type CreateScheduleAddItemPrice2 = { + /** + * Price per billing_units after included usage. Either 'amount' or 'tiers' is required. + */ + amount?: number | undefined; + /** + * Tiered pricing. Either 'amount' or 'tiers' is required. + */ + tiers?: Array | undefined; + tierBehavior?: CreateScheduleAddItemTierBehavior2 | undefined; + /** + * Billing interval. For consumable features, should match reset.interval. + */ + interval: CreateScheduleAddItemPriceInterval2; + /** + * Number of intervals per billing cycle. Defaults to 1. + */ + intervalCount?: number | undefined; + /** + * Units per price increment. Usage is rounded UP when billed (e.g. billing_units=100 means 101 rounds to 200). + */ + billingUnits?: number | undefined; + /** + * 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. + */ + billingMethod: CreateScheduleAddItemBillingMethod2; + /** + * Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit. + */ + maxPurchase?: number | null | undefined; +}; + +/** + * Billing behavior when quantity increases mid-cycle. + */ +export const CreateScheduleAddItemOnIncrease2 = { + BillImmediately: "bill_immediately", + ProrateImmediately: "prorate_immediately", + ProrateNextCycle: "prorate_next_cycle", + BillNextCycle: "bill_next_cycle", +} as const; +/** + * Billing behavior when quantity increases mid-cycle. + */ +export type CreateScheduleAddItemOnIncrease2 = ClosedEnum< + typeof CreateScheduleAddItemOnIncrease2 +>; + +/** + * Credit behavior when quantity decreases mid-cycle. + */ +export const CreateScheduleAddItemOnDecrease2 = { + Prorate: "prorate", + ProrateImmediately: "prorate_immediately", + ProrateNextCycle: "prorate_next_cycle", + None: "none", + NoProrations: "no_prorations", +} as const; +/** + * Credit behavior when quantity decreases mid-cycle. + */ +export type CreateScheduleAddItemOnDecrease2 = ClosedEnum< + typeof CreateScheduleAddItemOnDecrease2 +>; + +/** + * Proration settings for prepaid features. Controls mid-cycle quantity change billing. + */ +export type CreateScheduleAddItemProration2 = { + /** + * Billing behavior when quantity increases mid-cycle. + */ + onIncrease: CreateScheduleAddItemOnIncrease2; + /** + * Credit behavior when quantity decreases mid-cycle. + */ + onDecrease: CreateScheduleAddItemOnDecrease2; +}; + +/** + * When rolled over units expire. + */ +export const CreateScheduleAddItemExpiryDurationType2 = { + Month: "month", + Forever: "forever", +} as const; +/** + * When rolled over units expire. + */ +export type CreateScheduleAddItemExpiryDurationType2 = ClosedEnum< + typeof CreateScheduleAddItemExpiryDurationType2 +>; + +/** + * Rollover config for unused units. If set, unused included units carry over. + */ +export type CreateScheduleAddItemRollover2 = { + /** + * Max rollover units. Omit for unlimited rollover. + */ + max?: number | undefined; + /** + * Maximum rollover as a percentage (0-100) of included + prepaid grant. Mutually exclusive with max. + */ + maxPercentage?: number | undefined; + /** + * When rolled over units expire. + */ + expiryDurationType: CreateScheduleAddItemExpiryDurationType2; + /** + * Number of periods before expiry. + */ + expiryDurationLength?: number | undefined; +}; + +/** + * Configuration for a feature item in a plan, including usage limits, pricing, and rollover settings. + */ +export type CreateScheduleAddItemPlanItem2 = { + /** + * The ID of the feature to configure. + */ + featureId: string; + /** + * Number of free units included. Balance resets to this each interval for consumable features. + */ + included?: number | undefined; + /** + * If true, customer has unlimited access to this feature. + */ + unlimited?: boolean | undefined; + /** + * Reset configuration for consumable features. Omit for non-consumable features like seats. + */ + reset?: CreateScheduleAddItemReset2 | undefined; + /** + * Pricing for usage beyond included units. Omit for free features. + */ + price?: CreateScheduleAddItemPrice2 | undefined; + /** + * Proration settings for prepaid features. Controls mid-cycle quantity change billing. + */ + proration?: CreateScheduleAddItemProration2 | undefined; + /** + * Rollover config for unused units. If set, unused included units carry over. + */ + rollover?: CreateScheduleAddItemRollover2 | undefined; +}; + +/** + * Match items with this billing method (prepaid or usage_based). + */ +export const CreateScheduleRemoveItemBillingMethod2 = { + Prepaid: "prepaid", + UsageBased: "usage_based", +} as const; +/** + * Match items with this billing method (prepaid or usage_based). + */ +export type CreateScheduleRemoveItemBillingMethod2 = ClosedEnum< + typeof CreateScheduleRemoveItemBillingMethod2 +>; + +export const CreateScheduleIntervalRemoveItemEnum4 = { + OneOff: "one_off", + Minute: "minute", + Hour: "hour", + Day: "day", + Week: "week", + Month: "month", + Quarter: "quarter", + SemiAnnual: "semi_annual", + Year: "year", +} as const; +export type CreateScheduleIntervalRemoveItemEnum4 = ClosedEnum< + typeof CreateScheduleIntervalRemoveItemEnum4 +>; + +export const CreateScheduleIntervalRemoveItemEnum3 = { + OneOff: "one_off", + Week: "week", + Month: "month", + Quarter: "quarter", + SemiAnnual: "semi_annual", + Year: "year", +} as const; +export type CreateScheduleIntervalRemoveItemEnum3 = ClosedEnum< + typeof CreateScheduleIntervalRemoveItemEnum3 +>; + +/** + * 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. + */ +export type CreateScheduleIntervalUnion2 = + | CreateScheduleIntervalRemoveItemEnum3 + | CreateScheduleIntervalRemoveItemEnum4; + +/** + * Filter for matching plan items. All provided fields must match (AND). + */ +export type CreateSchedulePlanItemFilter2 = { + /** + * Match items linked to this feature. + */ + featureId?: string | undefined; + /** + * Match items with this billing method (prepaid or usage_based). + */ + billingMethod?: CreateScheduleRemoveItemBillingMethod2 | undefined; + /** + * 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?: + | CreateScheduleIntervalRemoveItemEnum3 + | CreateScheduleIntervalRemoveItemEnum4 + | undefined; + /** + * Match items with this interval_count. Disambiguates between items that share an interval but differ in count. + */ + intervalCount?: number | undefined; +}; + +/** + * Customize the plan to schedule. Can override price, replace items, or patch items with add_items, remove_items, and update_items. */ export type CreateScheduleCustomize2 = { /** @@ -378,9 +685,17 @@ export type CreateScheduleCustomize2 = { */ price?: CreateScheduleBasePrice2 | null | undefined; /** - * Override the items in the plan. + * Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items. */ - items?: Array | undefined; + items?: Array | undefined; + /** + * Items to add to the plan. + */ + addItems?: Array | undefined; + /** + * Filters selecting items to remove from the plan. + */ + removeItems?: Array | undefined; }; export type CreateSchedulePlan2 = { @@ -397,7 +712,7 @@ export type CreateSchedulePlan2 = { */ version?: number | undefined; /** - * Customize the plan to schedule. Can override the price, items, or both. + * Customize the plan to schedule. Can override price, replace items, or patch items with add_items, remove_items, and update_items. */ customize?: CreateScheduleCustomize2 | undefined; /** @@ -731,23 +1046,23 @@ export function createScheduleBasePrice2ToJSON( } /** @internal */ -export const CreateScheduleResetInterval2$outboundSchema: z.ZodMiniEnum< - typeof CreateScheduleResetInterval2 -> = z.enum(CreateScheduleResetInterval2); +export const CreateScheduleItemResetInterval2$outboundSchema: z.ZodMiniEnum< + typeof CreateScheduleItemResetInterval2 +> = z.enum(CreateScheduleItemResetInterval2); /** @internal */ -export type CreateScheduleReset2$Outbound = { +export type CreateScheduleItemReset2$Outbound = { interval: string; interval_count?: number | undefined; }; /** @internal */ -export const CreateScheduleReset2$outboundSchema: z.ZodMiniType< - CreateScheduleReset2$Outbound, - CreateScheduleReset2 +export const CreateScheduleItemReset2$outboundSchema: z.ZodMiniType< + CreateScheduleItemReset2$Outbound, + CreateScheduleItemReset2 > = z.pipe( z.object({ - interval: CreateScheduleResetInterval2$outboundSchema, + interval: CreateScheduleItemResetInterval2$outboundSchema, intervalCount: z.optional(z.number()), }), z.transform((v) => { @@ -757,25 +1072,25 @@ export const CreateScheduleReset2$outboundSchema: z.ZodMiniType< }), ); -export function createScheduleReset2ToJSON( - createScheduleReset2: CreateScheduleReset2, +export function createScheduleItemReset2ToJSON( + createScheduleItemReset2: CreateScheduleItemReset2, ): string { return JSON.stringify( - CreateScheduleReset2$outboundSchema.parse(createScheduleReset2), + CreateScheduleItemReset2$outboundSchema.parse(createScheduleItemReset2), ); } /** @internal */ -export type CreateScheduleTier2$Outbound = { +export type CreateScheduleItemTier2$Outbound = { to?: any | undefined; amount?: any | undefined; flat_amount?: any | undefined; }; /** @internal */ -export const CreateScheduleTier2$outboundSchema: z.ZodMiniType< - CreateScheduleTier2$Outbound, - CreateScheduleTier2 +export const CreateScheduleItemTier2$outboundSchema: z.ZodMiniType< + CreateScheduleItemTier2$Outbound, + CreateScheduleItemTier2 > = z.pipe( z.object({ to: z.optional(z.any()), @@ -789,18 +1104,18 @@ export const CreateScheduleTier2$outboundSchema: z.ZodMiniType< }), ); -export function createScheduleTier2ToJSON( - createScheduleTier2: CreateScheduleTier2, +export function createScheduleItemTier2ToJSON( + createScheduleItemTier2: CreateScheduleItemTier2, ): string { return JSON.stringify( - CreateScheduleTier2$outboundSchema.parse(createScheduleTier2), + CreateScheduleItemTier2$outboundSchema.parse(createScheduleItemTier2), ); } /** @internal */ -export const CreateScheduleTierBehavior2$outboundSchema: z.ZodMiniEnum< - typeof CreateScheduleTierBehavior2 -> = z.enum(CreateScheduleTierBehavior2); +export const CreateScheduleItemTierBehavior2$outboundSchema: z.ZodMiniEnum< + typeof CreateScheduleItemTierBehavior2 +> = z.enum(CreateScheduleItemTierBehavior2); /** @internal */ export const CreateScheduleItemPriceInterval2$outboundSchema: z.ZodMiniEnum< @@ -808,38 +1123,38 @@ export const CreateScheduleItemPriceInterval2$outboundSchema: z.ZodMiniEnum< > = z.enum(CreateScheduleItemPriceInterval2); /** @internal */ -export const CreateScheduleBillingMethod2$outboundSchema: z.ZodMiniEnum< - typeof CreateScheduleBillingMethod2 -> = z.enum(CreateScheduleBillingMethod2); +export const CreateScheduleItemBillingMethod2$outboundSchema: z.ZodMiniEnum< + typeof CreateScheduleItemBillingMethod2 +> = z.enum(CreateScheduleItemBillingMethod2); /** @internal */ -export type CreateSchedulePrice2$Outbound = { +export type CreateScheduleItemPrice2$Outbound = { amount?: number | undefined; - tiers?: Array | undefined; + tiers?: Array | undefined; tier_behavior?: string | undefined; interval: string; interval_count: number; billing_units: number; billing_method: string; - max_purchase?: number | undefined; + max_purchase?: number | null | undefined; }; /** @internal */ -export const CreateSchedulePrice2$outboundSchema: z.ZodMiniType< - CreateSchedulePrice2$Outbound, - CreateSchedulePrice2 +export const CreateScheduleItemPrice2$outboundSchema: z.ZodMiniType< + CreateScheduleItemPrice2$Outbound, + CreateScheduleItemPrice2 > = z.pipe( z.object({ amount: z.optional(z.number()), tiers: z.optional( - z.array(z.lazy(() => CreateScheduleTier2$outboundSchema)), + z.array(z.lazy(() => CreateScheduleItemTier2$outboundSchema)), ), - tierBehavior: z.optional(CreateScheduleTierBehavior2$outboundSchema), + tierBehavior: z.optional(CreateScheduleItemTierBehavior2$outboundSchema), interval: CreateScheduleItemPriceInterval2$outboundSchema, intervalCount: z._default(z.number(), 1), billingUnits: z._default(z.number(), 1), - billingMethod: CreateScheduleBillingMethod2$outboundSchema, - maxPurchase: z.optional(z.number()), + billingMethod: CreateScheduleItemBillingMethod2$outboundSchema, + maxPurchase: z.optional(z.nullable(z.number())), }), z.transform((v) => { return remap$(v, { @@ -852,38 +1167,38 @@ export const CreateSchedulePrice2$outboundSchema: z.ZodMiniType< }), ); -export function createSchedulePrice2ToJSON( - createSchedulePrice2: CreateSchedulePrice2, +export function createScheduleItemPrice2ToJSON( + createScheduleItemPrice2: CreateScheduleItemPrice2, ): string { return JSON.stringify( - CreateSchedulePrice2$outboundSchema.parse(createSchedulePrice2), + CreateScheduleItemPrice2$outboundSchema.parse(createScheduleItemPrice2), ); } /** @internal */ -export const CreateScheduleOnIncrease2$outboundSchema: z.ZodMiniEnum< - typeof CreateScheduleOnIncrease2 -> = z.enum(CreateScheduleOnIncrease2); +export const CreateScheduleItemOnIncrease2$outboundSchema: z.ZodMiniEnum< + typeof CreateScheduleItemOnIncrease2 +> = z.enum(CreateScheduleItemOnIncrease2); /** @internal */ -export const CreateScheduleOnDecrease2$outboundSchema: z.ZodMiniEnum< - typeof CreateScheduleOnDecrease2 -> = z.enum(CreateScheduleOnDecrease2); +export const CreateScheduleItemOnDecrease2$outboundSchema: z.ZodMiniEnum< + typeof CreateScheduleItemOnDecrease2 +> = z.enum(CreateScheduleItemOnDecrease2); /** @internal */ -export type CreateScheduleProration2$Outbound = { +export type CreateScheduleItemProration2$Outbound = { on_increase: string; on_decrease: string; }; /** @internal */ -export const CreateScheduleProration2$outboundSchema: z.ZodMiniType< - CreateScheduleProration2$Outbound, - CreateScheduleProration2 +export const CreateScheduleItemProration2$outboundSchema: z.ZodMiniType< + CreateScheduleItemProration2$Outbound, + CreateScheduleItemProration2 > = z.pipe( z.object({ - onIncrease: CreateScheduleOnIncrease2$outboundSchema, - onDecrease: CreateScheduleOnDecrease2$outboundSchema, + onIncrease: CreateScheduleItemOnIncrease2$outboundSchema, + onDecrease: CreateScheduleItemOnDecrease2$outboundSchema, }), z.transform((v) => { return remap$(v, { @@ -893,21 +1208,24 @@ export const CreateScheduleProration2$outboundSchema: z.ZodMiniType< }), ); -export function createScheduleProration2ToJSON( - createScheduleProration2: CreateScheduleProration2, +export function createScheduleItemProration2ToJSON( + createScheduleItemProration2: CreateScheduleItemProration2, ): string { return JSON.stringify( - CreateScheduleProration2$outboundSchema.parse(createScheduleProration2), + CreateScheduleItemProration2$outboundSchema.parse( + createScheduleItemProration2, + ), ); } /** @internal */ -export const CreateScheduleExpiryDurationType2$outboundSchema: z.ZodMiniEnum< - typeof CreateScheduleExpiryDurationType2 -> = z.enum(CreateScheduleExpiryDurationType2); +export const CreateScheduleItemExpiryDurationType2$outboundSchema: + z.ZodMiniEnum = z.enum( + CreateScheduleItemExpiryDurationType2, + ); /** @internal */ -export type CreateScheduleRollover2$Outbound = { +export type CreateScheduleItemRollover2$Outbound = { max?: number | undefined; max_percentage?: number | undefined; expiry_duration_type: string; @@ -915,14 +1233,14 @@ export type CreateScheduleRollover2$Outbound = { }; /** @internal */ -export const CreateScheduleRollover2$outboundSchema: z.ZodMiniType< - CreateScheduleRollover2$Outbound, - CreateScheduleRollover2 +export const CreateScheduleItemRollover2$outboundSchema: z.ZodMiniType< + CreateScheduleItemRollover2$Outbound, + CreateScheduleItemRollover2 > = z.pipe( z.object({ max: z.optional(z.number()), maxPercentage: z.optional(z.number()), - expiryDurationType: CreateScheduleExpiryDurationType2$outboundSchema, + expiryDurationType: CreateScheduleItemExpiryDurationType2$outboundSchema, expiryDurationLength: z.optional(z.number()), }), z.transform((v) => { @@ -934,40 +1252,44 @@ export const CreateScheduleRollover2$outboundSchema: z.ZodMiniType< }), ); -export function createScheduleRollover2ToJSON( - createScheduleRollover2: CreateScheduleRollover2, +export function createScheduleItemRollover2ToJSON( + createScheduleItemRollover2: CreateScheduleItemRollover2, ): string { return JSON.stringify( - CreateScheduleRollover2$outboundSchema.parse(createScheduleRollover2), + CreateScheduleItemRollover2$outboundSchema.parse( + createScheduleItemRollover2, + ), ); } /** @internal */ -export type CreateSchedulePlanItem2$Outbound = { +export type CreateScheduleItemPlanItem2$Outbound = { feature_id: string; included?: number | undefined; unlimited?: boolean | undefined; - reset?: CreateScheduleReset2$Outbound | undefined; - price?: CreateSchedulePrice2$Outbound | undefined; - proration?: CreateScheduleProration2$Outbound | undefined; - rollover?: CreateScheduleRollover2$Outbound | undefined; + reset?: CreateScheduleItemReset2$Outbound | undefined; + price?: CreateScheduleItemPrice2$Outbound | undefined; + proration?: CreateScheduleItemProration2$Outbound | undefined; + rollover?: CreateScheduleItemRollover2$Outbound | undefined; }; /** @internal */ -export const CreateSchedulePlanItem2$outboundSchema: z.ZodMiniType< - CreateSchedulePlanItem2$Outbound, - CreateSchedulePlanItem2 +export const CreateScheduleItemPlanItem2$outboundSchema: z.ZodMiniType< + CreateScheduleItemPlanItem2$Outbound, + CreateScheduleItemPlanItem2 > = z.pipe( z.object({ featureId: z.string(), included: z.optional(z.number()), unlimited: z.optional(z.boolean()), - reset: z.optional(z.lazy(() => CreateScheduleReset2$outboundSchema)), - price: z.optional(z.lazy(() => CreateSchedulePrice2$outboundSchema)), + reset: z.optional(z.lazy(() => CreateScheduleItemReset2$outboundSchema)), + price: z.optional(z.lazy(() => CreateScheduleItemPrice2$outboundSchema)), proration: z.optional( - z.lazy(() => CreateScheduleProration2$outboundSchema), + z.lazy(() => CreateScheduleItemProration2$outboundSchema), + ), + rollover: z.optional( + z.lazy(() => CreateScheduleItemRollover2$outboundSchema), ), - rollover: z.optional(z.lazy(() => CreateScheduleRollover2$outboundSchema)), }), z.transform((v) => { return remap$(v, { @@ -976,32 +1298,402 @@ export const CreateSchedulePlanItem2$outboundSchema: z.ZodMiniType< }), ); -export function createSchedulePlanItem2ToJSON( - createSchedulePlanItem2: CreateSchedulePlanItem2, +export function createScheduleItemPlanItem2ToJSON( + createScheduleItemPlanItem2: CreateScheduleItemPlanItem2, ): string { return JSON.stringify( - CreateSchedulePlanItem2$outboundSchema.parse(createSchedulePlanItem2), + CreateScheduleItemPlanItem2$outboundSchema.parse( + createScheduleItemPlanItem2, + ), + ); +} + +/** @internal */ +export const CreateScheduleAddItemResetInterval2$outboundSchema: z.ZodMiniEnum< + typeof CreateScheduleAddItemResetInterval2 +> = z.enum(CreateScheduleAddItemResetInterval2); + +/** @internal */ +export type CreateScheduleAddItemReset2$Outbound = { + interval: string; + interval_count?: number | undefined; +}; + +/** @internal */ +export const CreateScheduleAddItemReset2$outboundSchema: z.ZodMiniType< + CreateScheduleAddItemReset2$Outbound, + CreateScheduleAddItemReset2 +> = z.pipe( + z.object({ + interval: CreateScheduleAddItemResetInterval2$outboundSchema, + intervalCount: z.optional(z.number()), + }), + z.transform((v) => { + return remap$(v, { + intervalCount: "interval_count", + }); + }), +); + +export function createScheduleAddItemReset2ToJSON( + createScheduleAddItemReset2: CreateScheduleAddItemReset2, +): string { + return JSON.stringify( + CreateScheduleAddItemReset2$outboundSchema.parse( + createScheduleAddItemReset2, + ), + ); +} + +/** @internal */ +export type CreateScheduleAddItemTier2$Outbound = { + to?: any | undefined; + amount?: any | undefined; + flat_amount?: any | undefined; +}; + +/** @internal */ +export const CreateScheduleAddItemTier2$outboundSchema: z.ZodMiniType< + CreateScheduleAddItemTier2$Outbound, + CreateScheduleAddItemTier2 +> = z.pipe( + z.object({ + to: z.optional(z.any()), + amount: z.optional(z.any()), + flatAmount: z.optional(z.any()), + }), + z.transform((v) => { + return remap$(v, { + flatAmount: "flat_amount", + }); + }), +); + +export function createScheduleAddItemTier2ToJSON( + createScheduleAddItemTier2: CreateScheduleAddItemTier2, +): string { + return JSON.stringify( + CreateScheduleAddItemTier2$outboundSchema.parse(createScheduleAddItemTier2), + ); +} + +/** @internal */ +export const CreateScheduleAddItemTierBehavior2$outboundSchema: z.ZodMiniEnum< + typeof CreateScheduleAddItemTierBehavior2 +> = z.enum(CreateScheduleAddItemTierBehavior2); + +/** @internal */ +export const CreateScheduleAddItemPriceInterval2$outboundSchema: z.ZodMiniEnum< + typeof CreateScheduleAddItemPriceInterval2 +> = z.enum(CreateScheduleAddItemPriceInterval2); + +/** @internal */ +export const CreateScheduleAddItemBillingMethod2$outboundSchema: z.ZodMiniEnum< + typeof CreateScheduleAddItemBillingMethod2 +> = z.enum(CreateScheduleAddItemBillingMethod2); + +/** @internal */ +export type CreateScheduleAddItemPrice2$Outbound = { + amount?: number | undefined; + tiers?: Array | undefined; + tier_behavior?: string | undefined; + interval: string; + interval_count: number; + billing_units: number; + billing_method: string; + max_purchase?: number | null | undefined; +}; + +/** @internal */ +export const CreateScheduleAddItemPrice2$outboundSchema: z.ZodMiniType< + CreateScheduleAddItemPrice2$Outbound, + CreateScheduleAddItemPrice2 +> = z.pipe( + z.object({ + amount: z.optional(z.number()), + tiers: z.optional( + z.array(z.lazy(() => CreateScheduleAddItemTier2$outboundSchema)), + ), + tierBehavior: z.optional(CreateScheduleAddItemTierBehavior2$outboundSchema), + interval: CreateScheduleAddItemPriceInterval2$outboundSchema, + intervalCount: z._default(z.number(), 1), + billingUnits: z._default(z.number(), 1), + billingMethod: CreateScheduleAddItemBillingMethod2$outboundSchema, + maxPurchase: z.optional(z.nullable(z.number())), + }), + z.transform((v) => { + return remap$(v, { + tierBehavior: "tier_behavior", + intervalCount: "interval_count", + billingUnits: "billing_units", + billingMethod: "billing_method", + maxPurchase: "max_purchase", + }); + }), +); + +export function createScheduleAddItemPrice2ToJSON( + createScheduleAddItemPrice2: CreateScheduleAddItemPrice2, +): string { + return JSON.stringify( + CreateScheduleAddItemPrice2$outboundSchema.parse( + createScheduleAddItemPrice2, + ), + ); +} + +/** @internal */ +export const CreateScheduleAddItemOnIncrease2$outboundSchema: z.ZodMiniEnum< + typeof CreateScheduleAddItemOnIncrease2 +> = z.enum(CreateScheduleAddItemOnIncrease2); + +/** @internal */ +export const CreateScheduleAddItemOnDecrease2$outboundSchema: z.ZodMiniEnum< + typeof CreateScheduleAddItemOnDecrease2 +> = z.enum(CreateScheduleAddItemOnDecrease2); + +/** @internal */ +export type CreateScheduleAddItemProration2$Outbound = { + on_increase: string; + on_decrease: string; +}; + +/** @internal */ +export const CreateScheduleAddItemProration2$outboundSchema: z.ZodMiniType< + CreateScheduleAddItemProration2$Outbound, + CreateScheduleAddItemProration2 +> = z.pipe( + z.object({ + onIncrease: CreateScheduleAddItemOnIncrease2$outboundSchema, + onDecrease: CreateScheduleAddItemOnDecrease2$outboundSchema, + }), + z.transform((v) => { + return remap$(v, { + onIncrease: "on_increase", + onDecrease: "on_decrease", + }); + }), +); + +export function createScheduleAddItemProration2ToJSON( + createScheduleAddItemProration2: CreateScheduleAddItemProration2, +): string { + return JSON.stringify( + CreateScheduleAddItemProration2$outboundSchema.parse( + createScheduleAddItemProration2, + ), + ); +} + +/** @internal */ +export const CreateScheduleAddItemExpiryDurationType2$outboundSchema: + z.ZodMiniEnum = z.enum( + CreateScheduleAddItemExpiryDurationType2, + ); + +/** @internal */ +export type CreateScheduleAddItemRollover2$Outbound = { + max?: number | undefined; + max_percentage?: number | undefined; + expiry_duration_type: string; + expiry_duration_length?: number | undefined; +}; + +/** @internal */ +export const CreateScheduleAddItemRollover2$outboundSchema: z.ZodMiniType< + CreateScheduleAddItemRollover2$Outbound, + CreateScheduleAddItemRollover2 +> = z.pipe( + z.object({ + max: z.optional(z.number()), + maxPercentage: z.optional(z.number()), + expiryDurationType: CreateScheduleAddItemExpiryDurationType2$outboundSchema, + expiryDurationLength: z.optional(z.number()), + }), + z.transform((v) => { + return remap$(v, { + maxPercentage: "max_percentage", + expiryDurationType: "expiry_duration_type", + expiryDurationLength: "expiry_duration_length", + }); + }), +); + +export function createScheduleAddItemRollover2ToJSON( + createScheduleAddItemRollover2: CreateScheduleAddItemRollover2, +): string { + return JSON.stringify( + CreateScheduleAddItemRollover2$outboundSchema.parse( + createScheduleAddItemRollover2, + ), + ); +} + +/** @internal */ +export type CreateScheduleAddItemPlanItem2$Outbound = { + feature_id: string; + included?: number | undefined; + unlimited?: boolean | undefined; + reset?: CreateScheduleAddItemReset2$Outbound | undefined; + price?: CreateScheduleAddItemPrice2$Outbound | undefined; + proration?: CreateScheduleAddItemProration2$Outbound | undefined; + rollover?: CreateScheduleAddItemRollover2$Outbound | undefined; +}; + +/** @internal */ +export const CreateScheduleAddItemPlanItem2$outboundSchema: z.ZodMiniType< + CreateScheduleAddItemPlanItem2$Outbound, + CreateScheduleAddItemPlanItem2 +> = z.pipe( + z.object({ + featureId: z.string(), + included: z.optional(z.number()), + unlimited: z.optional(z.boolean()), + reset: z.optional(z.lazy(() => CreateScheduleAddItemReset2$outboundSchema)), + price: z.optional(z.lazy(() => CreateScheduleAddItemPrice2$outboundSchema)), + proration: z.optional( + z.lazy(() => CreateScheduleAddItemProration2$outboundSchema), + ), + rollover: z.optional( + z.lazy(() => CreateScheduleAddItemRollover2$outboundSchema), + ), + }), + z.transform((v) => { + return remap$(v, { + featureId: "feature_id", + }); + }), +); + +export function createScheduleAddItemPlanItem2ToJSON( + createScheduleAddItemPlanItem2: CreateScheduleAddItemPlanItem2, +): string { + return JSON.stringify( + CreateScheduleAddItemPlanItem2$outboundSchema.parse( + createScheduleAddItemPlanItem2, + ), + ); +} + +/** @internal */ +export const CreateScheduleRemoveItemBillingMethod2$outboundSchema: + z.ZodMiniEnum = z.enum( + CreateScheduleRemoveItemBillingMethod2, + ); + +/** @internal */ +export const CreateScheduleIntervalRemoveItemEnum4$outboundSchema: + z.ZodMiniEnum = z.enum( + CreateScheduleIntervalRemoveItemEnum4, + ); + +/** @internal */ +export const CreateScheduleIntervalRemoveItemEnum3$outboundSchema: + z.ZodMiniEnum = z.enum( + CreateScheduleIntervalRemoveItemEnum3, + ); + +/** @internal */ +export type CreateScheduleIntervalUnion2$Outbound = string | string; + +/** @internal */ +export const CreateScheduleIntervalUnion2$outboundSchema: z.ZodMiniType< + CreateScheduleIntervalUnion2$Outbound, + CreateScheduleIntervalUnion2 +> = smartUnion([ + CreateScheduleIntervalRemoveItemEnum3$outboundSchema, + CreateScheduleIntervalRemoveItemEnum4$outboundSchema, +]); + +export function createScheduleIntervalUnion2ToJSON( + createScheduleIntervalUnion2: CreateScheduleIntervalUnion2, +): string { + return JSON.stringify( + CreateScheduleIntervalUnion2$outboundSchema.parse( + createScheduleIntervalUnion2, + ), + ); +} + +/** @internal */ +export type CreateSchedulePlanItemFilter2$Outbound = { + feature_id?: string | undefined; + billing_method?: string | undefined; + interval?: string | string | undefined; + interval_count?: number | undefined; +}; + +/** @internal */ +export const CreateSchedulePlanItemFilter2$outboundSchema: z.ZodMiniType< + CreateSchedulePlanItemFilter2$Outbound, + CreateSchedulePlanItemFilter2 +> = z.pipe( + z.object({ + featureId: z.optional(z.string()), + billingMethod: z.optional( + CreateScheduleRemoveItemBillingMethod2$outboundSchema, + ), + interval: z.optional( + smartUnion([ + CreateScheduleIntervalRemoveItemEnum3$outboundSchema, + CreateScheduleIntervalRemoveItemEnum4$outboundSchema, + ]), + ), + intervalCount: z.optional(z.int()), + }), + z.transform((v) => { + return remap$(v, { + featureId: "feature_id", + billingMethod: "billing_method", + intervalCount: "interval_count", + }); + }), +); + +export function createSchedulePlanItemFilter2ToJSON( + createSchedulePlanItemFilter2: CreateSchedulePlanItemFilter2, +): string { + return JSON.stringify( + CreateSchedulePlanItemFilter2$outboundSchema.parse( + createSchedulePlanItemFilter2, + ), ); } /** @internal */ export type CreateScheduleCustomize2$Outbound = { price?: CreateScheduleBasePrice2$Outbound | null | undefined; - items?: Array | undefined; + items?: Array | undefined; + add_items?: Array | undefined; + remove_items?: Array | undefined; }; /** @internal */ export const CreateScheduleCustomize2$outboundSchema: z.ZodMiniType< CreateScheduleCustomize2$Outbound, CreateScheduleCustomize2 -> = z.object({ - price: z.optional( - z.nullable(z.lazy(() => CreateScheduleBasePrice2$outboundSchema)), - ), - items: z.optional( - z.array(z.lazy(() => CreateSchedulePlanItem2$outboundSchema)), - ), -}); +> = z.pipe( + z.object({ + price: z.optional( + z.nullable(z.lazy(() => CreateScheduleBasePrice2$outboundSchema)), + ), + items: z.optional( + z.array(z.lazy(() => CreateScheduleItemPlanItem2$outboundSchema)), + ), + addItems: z.optional( + z.array(z.lazy(() => CreateScheduleAddItemPlanItem2$outboundSchema)), + ), + removeItems: z.optional( + z.array(z.lazy(() => CreateSchedulePlanItemFilter2$outboundSchema)), + ), + }), + z.transform((v) => { + return remap$(v, { + addItems: "add_items", + removeItems: "remove_items", + }); + }), +); export function createScheduleCustomize2ToJSON( createScheduleCustomize2: CreateScheduleCustomize2, diff --git a/packages/sdk/src/models/customer.ts b/packages/sdk/src/models/customer.ts index 80b661b2e..bdadd80af 100644 --- a/packages/sdk/src/models/customer.ts +++ b/packages/sdk/src/models/customer.ts @@ -326,15 +326,16 @@ export type Purchase = { }; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export const CustomerFlagsType = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export type CustomerFlagsType = OpenEnum; @@ -349,6 +350,16 @@ export type CustomerCreditSchema = { creditCost: number; }; +export type CustomerModelMarkups = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type CustomerProviderMarkups = { + markup: number; +}; + /** * Display names for the feature in billing UI and customer-facing components. */ @@ -376,7 +387,7 @@ export type CustomerFeature = { */ name: string; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ type: CustomerFlagsType; /** @@ -391,6 +402,18 @@ export type CustomerFeature = { * For credit_system features: maps metered features to their credit costs. */ creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. + */ + modelMarkups?: { [k: string]: CustomerModelMarkups } | null | undefined; + /** + * Default percentage markup for AI credit systems. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. + */ + providerMarkups?: { [k: string]: CustomerProviderMarkups } | null | undefined; /** * Display names for the feature in billing UI and customer-facing components. */ @@ -1146,6 +1169,52 @@ export function customerCreditSchemaFromJSON( ); } +/** @internal */ +export const CustomerModelMarkups$inboundSchema: z.ZodMiniType< + CustomerModelMarkups, + unknown +> = z.pipe( + z.object({ + markup: types.optional(types.number()), + input_cost: types.optional(types.number()), + output_cost: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "input_cost": "inputCost", + "output_cost": "outputCost", + }); + }), +); + +export function customerModelMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => CustomerModelMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CustomerModelMarkups' from JSON`, + ); +} + +/** @internal */ +export const CustomerProviderMarkups$inboundSchema: z.ZodMiniType< + CustomerProviderMarkups, + unknown +> = z.object({ + markup: types.number(), +}); + +export function customerProviderMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => CustomerProviderMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CustomerProviderMarkups' from JSON`, + ); +} + /** @internal */ export const CustomerDisplay$inboundSchema: z.ZodMiniType< CustomerDisplay, @@ -1179,13 +1248,27 @@ export const CustomerFeature$inboundSchema: z.ZodMiniType< credit_schema: types.optional( z.array(z.lazy(() => CustomerCreditSchema$inboundSchema)), ), - display: types.optional(z.lazy(() => CustomerDisplay$inboundSchema)), + model_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => CustomerModelMarkups$inboundSchema), + ))), + default_markup: types.optional(types.number()), + provider_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => CustomerProviderMarkups$inboundSchema), + ))), + display: types.optional(z.lazy(() => + CustomerDisplay$inboundSchema + )), archived: types.boolean(), }), z.transform((v) => { return remap$(v, { "event_names": "eventNames", "credit_schema": "creditSchema", + "model_markups": "modelMarkups", + "default_markup": "defaultMarkup", + "provider_markups": "providerMarkups", }); }), ); diff --git a/packages/sdk/src/models/get-customer-op.ts b/packages/sdk/src/models/get-customer-op.ts index 35f1dee91..4587b12a5 100644 --- a/packages/sdk/src/models/get-customer-op.ts +++ b/packages/sdk/src/models/get-customer-op.ts @@ -354,15 +354,16 @@ export type GetCustomerPurchase = { }; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export const GetCustomerFlagsType = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export type GetCustomerFlagsType = OpenEnum; @@ -377,6 +378,16 @@ export type GetCustomerCreditSchema = { creditCost: number; }; +export type GetCustomerModelMarkups = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type GetCustomerProviderMarkups = { + markup: number; +}; + /** * Display names for the feature in billing UI and customer-facing components. */ @@ -404,7 +415,7 @@ export type GetCustomerFeature = { */ name: string; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ type: GetCustomerFlagsType; /** @@ -419,6 +430,21 @@ export type GetCustomerFeature = { * For credit_system features: maps metered features to their credit costs. */ creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. + */ + modelMarkups?: { [k: string]: GetCustomerModelMarkups } | null | undefined; + /** + * Default percentage markup for AI credit systems. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. + */ + providerMarkups?: + | { [k: string]: GetCustomerProviderMarkups } + | null + | undefined; /** * Display names for the feature in billing UI and customer-facing components. */ @@ -1216,6 +1242,52 @@ export function getCustomerCreditSchemaFromJSON( ); } +/** @internal */ +export const GetCustomerModelMarkups$inboundSchema: z.ZodMiniType< + GetCustomerModelMarkups, + unknown +> = z.pipe( + z.object({ + markup: types.optional(types.number()), + input_cost: types.optional(types.number()), + output_cost: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "input_cost": "inputCost", + "output_cost": "outputCost", + }); + }), +); + +export function getCustomerModelMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => GetCustomerModelMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'GetCustomerModelMarkups' from JSON`, + ); +} + +/** @internal */ +export const GetCustomerProviderMarkups$inboundSchema: z.ZodMiniType< + GetCustomerProviderMarkups, + unknown +> = z.object({ + markup: types.number(), +}); + +export function getCustomerProviderMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => GetCustomerProviderMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'GetCustomerProviderMarkups' from JSON`, + ); +} + /** @internal */ export const GetCustomerDisplay$inboundSchema: z.ZodMiniType< GetCustomerDisplay, @@ -1249,13 +1321,27 @@ export const GetCustomerFeature$inboundSchema: z.ZodMiniType< credit_schema: types.optional( z.array(z.lazy(() => GetCustomerCreditSchema$inboundSchema)), ), - display: types.optional(z.lazy(() => GetCustomerDisplay$inboundSchema)), + model_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => GetCustomerModelMarkups$inboundSchema), + ))), + default_markup: types.optional(types.number()), + provider_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => GetCustomerProviderMarkups$inboundSchema), + ))), + display: types.optional(z.lazy(() => + GetCustomerDisplay$inboundSchema + )), archived: types.boolean(), }), z.transform((v) => { return remap$(v, { "event_names": "eventNames", "credit_schema": "creditSchema", + "model_markups": "modelMarkups", + "default_markup": "defaultMarkup", + "provider_markups": "providerMarkups", }); }), ); diff --git a/packages/sdk/src/models/get-entity-op.ts b/packages/sdk/src/models/get-entity-op.ts index 38052f3bf..48ea79f0a 100644 --- a/packages/sdk/src/models/get-entity-op.ts +++ b/packages/sdk/src/models/get-entity-op.ts @@ -163,15 +163,16 @@ export type GetEntityPurchase = { }; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export const GetEntityType = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export type GetEntityType = OpenEnum; @@ -186,6 +187,16 @@ export type GetEntityCreditSchema = { creditCost: number; }; +export type GetEntityModelMarkups = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type GetEntityProviderMarkups = { + markup: number; +}; + /** * Display names for the feature in billing UI and customer-facing components. */ @@ -213,7 +224,7 @@ export type GetEntityFeature = { */ name: string; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ type: GetEntityType; /** @@ -228,6 +239,21 @@ export type GetEntityFeature = { * For credit_system features: maps metered features to their credit costs. */ creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. + */ + modelMarkups?: { [k: string]: GetEntityModelMarkups } | null | undefined; + /** + * Default percentage markup for AI credit systems. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. + */ + providerMarkups?: + | { [k: string]: GetEntityProviderMarkups } + | null + | undefined; /** * Display names for the feature in billing UI and customer-facing components. */ @@ -595,6 +621,52 @@ export function getEntityCreditSchemaFromJSON( ); } +/** @internal */ +export const GetEntityModelMarkups$inboundSchema: z.ZodMiniType< + GetEntityModelMarkups, + unknown +> = z.pipe( + z.object({ + markup: types.optional(types.number()), + input_cost: types.optional(types.number()), + output_cost: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "input_cost": "inputCost", + "output_cost": "outputCost", + }); + }), +); + +export function getEntityModelMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => GetEntityModelMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'GetEntityModelMarkups' from JSON`, + ); +} + +/** @internal */ +export const GetEntityProviderMarkups$inboundSchema: z.ZodMiniType< + GetEntityProviderMarkups, + unknown +> = z.object({ + markup: types.number(), +}); + +export function getEntityProviderMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => GetEntityProviderMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'GetEntityProviderMarkups' from JSON`, + ); +} + /** @internal */ export const GetEntityDisplay$inboundSchema: z.ZodMiniType< GetEntityDisplay, @@ -628,13 +700,27 @@ export const GetEntityFeature$inboundSchema: z.ZodMiniType< credit_schema: types.optional( z.array(z.lazy(() => GetEntityCreditSchema$inboundSchema)), ), - display: types.optional(z.lazy(() => GetEntityDisplay$inboundSchema)), + model_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => GetEntityModelMarkups$inboundSchema), + ))), + default_markup: types.optional(types.number()), + provider_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => GetEntityProviderMarkups$inboundSchema), + ))), + display: types.optional(z.lazy(() => + GetEntityDisplay$inboundSchema + )), archived: types.boolean(), }), z.transform((v) => { return remap$(v, { "event_names": "eventNames", "credit_schema": "creditSchema", + "model_markups": "modelMarkups", + "default_markup": "defaultMarkup", + "provider_markups": "providerMarkups", }); }), ); diff --git a/packages/sdk/src/models/get-feature-op.ts b/packages/sdk/src/models/get-feature-op.ts index 2dab76561..0097008c9 100644 --- a/packages/sdk/src/models/get-feature-op.ts +++ b/packages/sdk/src/models/get-feature-op.ts @@ -23,15 +23,16 @@ export type GetFeatureParams = { }; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export const GetFeatureType = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export type GetFeatureType = OpenEnum; @@ -46,6 +47,16 @@ export type GetFeatureCreditSchema = { creditCost: number; }; +export type GetFeatureModelMarkups = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type GetFeatureProviderMarkups = { + markup: number; +}; + /** * Display names for the feature in billing UI and customer-facing components. */ @@ -73,7 +84,7 @@ export type GetFeatureResponse = { */ name: string; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ type: GetFeatureType; /** @@ -88,6 +99,21 @@ export type GetFeatureResponse = { * For credit_system features: maps metered features to their credit costs. */ creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. + */ + modelMarkups?: { [k: string]: GetFeatureModelMarkups } | null | undefined; + /** + * Default percentage markup for AI credit systems. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. + */ + providerMarkups?: + | { [k: string]: GetFeatureProviderMarkups } + | null + | undefined; /** * Display names for the feature in billing UI and customer-facing components. */ @@ -159,6 +185,52 @@ export function getFeatureCreditSchemaFromJSON( ); } +/** @internal */ +export const GetFeatureModelMarkups$inboundSchema: z.ZodMiniType< + GetFeatureModelMarkups, + unknown +> = z.pipe( + z.object({ + markup: types.optional(types.number()), + input_cost: types.optional(types.number()), + output_cost: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "input_cost": "inputCost", + "output_cost": "outputCost", + }); + }), +); + +export function getFeatureModelMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => GetFeatureModelMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'GetFeatureModelMarkups' from JSON`, + ); +} + +/** @internal */ +export const GetFeatureProviderMarkups$inboundSchema: z.ZodMiniType< + GetFeatureProviderMarkups, + unknown +> = z.object({ + markup: types.number(), +}); + +export function getFeatureProviderMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => GetFeatureProviderMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'GetFeatureProviderMarkups' from JSON`, + ); +} + /** @internal */ export const GetFeatureDisplay$inboundSchema: z.ZodMiniType< GetFeatureDisplay, @@ -192,13 +264,27 @@ export const GetFeatureResponse$inboundSchema: z.ZodMiniType< credit_schema: types.optional( z.array(z.lazy(() => GetFeatureCreditSchema$inboundSchema)), ), - display: types.optional(z.lazy(() => GetFeatureDisplay$inboundSchema)), + model_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => GetFeatureModelMarkups$inboundSchema), + ))), + default_markup: types.optional(types.number()), + provider_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => GetFeatureProviderMarkups$inboundSchema), + ))), + display: types.optional(z.lazy(() => + GetFeatureDisplay$inboundSchema + )), archived: types.boolean(), }), z.transform((v) => { return remap$(v, { "event_names": "eventNames", "credit_schema": "creditSchema", + "model_markups": "modelMarkups", + "default_markup": "defaultMarkup", + "provider_markups": "providerMarkups", }); }), ); diff --git a/packages/sdk/src/models/get-plan-op.ts b/packages/sdk/src/models/get-plan-op.ts index 43ea5f27a..a7e532169 100644 --- a/packages/sdk/src/models/get-plan-op.ts +++ b/packages/sdk/src/models/get-plan-op.ts @@ -9,6 +9,7 @@ import * as openEnums from "../types/enums.js"; import { OpenEnum } from "../types/enums.js"; import { Result as SafeParseResult } from "../types/fp.js"; import * as types from "../types/primitives.js"; +import { smartUnion } from "../types/smart-union.js"; import { SDKValidationError } from "./sdk-validation-error.js"; export type GetPlanGlobals = { @@ -84,6 +85,7 @@ export const GetPlanType = { SingleUse: "single_use", ContinuousUse: "continuous_use", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** * The type of the feature @@ -172,6 +174,14 @@ export type GetPlanReset = { intervalCount?: number | undefined; }; +export type GetPlanTo = number | string; + +export type GetPlanTier = { + to: number | string; + amount: number; + flatAmount?: number | undefined; +}; + export const GetPlanTierBehavior = { Graduated: "graduated", Volume: "volume", @@ -216,7 +226,7 @@ export type GetPlanItemPrice = { /** * Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. */ - tiers?: Array | undefined; + tiers?: Array | undefined; tierBehavior?: GetPlanTierBehavior | undefined; /** * Billing interval for this price. For consumable features, should match reset.interval. @@ -704,6 +714,45 @@ export function getPlanResetFromJSON( ); } +/** @internal */ +export const GetPlanTo$inboundSchema: z.ZodMiniType = + smartUnion([types.number(), types.string()]); + +export function getPlanToFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => GetPlanTo$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'GetPlanTo' from JSON`, + ); +} + +/** @internal */ +export const GetPlanTier$inboundSchema: z.ZodMiniType = z + .pipe( + z.object({ + to: smartUnion([types.number(), types.string()]), + amount: types.number(), + flat_amount: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "flat_amount": "flatAmount", + }); + }), + ); + +export function getPlanTierFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => GetPlanTier$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'GetPlanTier' from JSON`, + ); +} + /** @internal */ export const GetPlanTierBehavior$inboundSchema: z.ZodMiniType< GetPlanTierBehavior, @@ -729,7 +778,7 @@ export const GetPlanItemPrice$inboundSchema: z.ZodMiniType< > = z.pipe( z.object({ amount: types.optional(types.number()), - tiers: types.optional(z.array(types.nullable(z.any()))), + tiers: types.optional(z.array(z.lazy(() => GetPlanTier$inboundSchema))), tier_behavior: types.optional(GetPlanTierBehavior$inboundSchema), interval: GetPlanPriceItemInterval$inboundSchema, interval_count: types.optional(types.number()), diff --git a/packages/sdk/src/models/index.ts b/packages/sdk/src/models/index.ts index 9c3a0afcd..00397486e 100644 --- a/packages/sdk/src/models/index.ts +++ b/packages/sdk/src/models/index.ts @@ -52,6 +52,7 @@ export * from "./security.js"; export * from "./setup-payment-op.js"; export * from "./sync-revenue-cat-op.js"; export * from "./track-op.js"; +export * from "./track-tokens-op.js"; export * from "./update-balance-op.js"; export * from "./update-customer-op.js"; export * from "./update-entity-op.js"; diff --git a/packages/sdk/src/models/list-customers-op.ts b/packages/sdk/src/models/list-customers-op.ts index 7f4377171..129da03ae 100644 --- a/packages/sdk/src/models/list-customers-op.ts +++ b/packages/sdk/src/models/list-customers-op.ts @@ -392,15 +392,16 @@ export type ListCustomersPurchase = { }; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export const ListCustomersType = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export type ListCustomersType = OpenEnum; @@ -415,6 +416,16 @@ export type ListCustomersCreditSchema = { creditCost: number; }; +export type ListCustomersModelMarkups = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type ListCustomersProviderMarkups = { + markup: number; +}; + /** * Display names for the feature in billing UI and customer-facing components. */ @@ -442,7 +453,7 @@ export type ListCustomersFeature = { */ name: string; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ type: ListCustomersType; /** @@ -457,6 +468,21 @@ export type ListCustomersFeature = { * For credit_system features: maps metered features to their credit costs. */ creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. + */ + modelMarkups?: { [k: string]: ListCustomersModelMarkups } | null | undefined; + /** + * Default percentage markup for AI credit systems. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. + */ + providerMarkups?: + | { [k: string]: ListCustomersProviderMarkups } + | null + | undefined; /** * Display names for the feature in billing UI and customer-facing components. */ @@ -1096,6 +1122,52 @@ export function listCustomersCreditSchemaFromJSON( ); } +/** @internal */ +export const ListCustomersModelMarkups$inboundSchema: z.ZodMiniType< + ListCustomersModelMarkups, + unknown +> = z.pipe( + z.object({ + markup: types.optional(types.number()), + input_cost: types.optional(types.number()), + output_cost: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "input_cost": "inputCost", + "output_cost": "outputCost", + }); + }), +); + +export function listCustomersModelMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => ListCustomersModelMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'ListCustomersModelMarkups' from JSON`, + ); +} + +/** @internal */ +export const ListCustomersProviderMarkups$inboundSchema: z.ZodMiniType< + ListCustomersProviderMarkups, + unknown +> = z.object({ + markup: types.number(), +}); + +export function listCustomersProviderMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => ListCustomersProviderMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'ListCustomersProviderMarkups' from JSON`, + ); +} + /** @internal */ export const ListCustomersDisplay$inboundSchema: z.ZodMiniType< ListCustomersDisplay, @@ -1129,13 +1201,27 @@ export const ListCustomersFeature$inboundSchema: z.ZodMiniType< credit_schema: types.optional( z.array(z.lazy(() => ListCustomersCreditSchema$inboundSchema)), ), - display: types.optional(z.lazy(() => ListCustomersDisplay$inboundSchema)), + model_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => ListCustomersModelMarkups$inboundSchema), + ))), + default_markup: types.optional(types.number()), + provider_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => ListCustomersProviderMarkups$inboundSchema), + ))), + display: types.optional(z.lazy(() => + ListCustomersDisplay$inboundSchema + )), archived: types.boolean(), }), z.transform((v) => { return remap$(v, { "event_names": "eventNames", "credit_schema": "creditSchema", + "model_markups": "modelMarkups", + "default_markup": "defaultMarkup", + "provider_markups": "providerMarkups", }); }), ); diff --git a/packages/sdk/src/models/list-entities-op.ts b/packages/sdk/src/models/list-entities-op.ts index eedb101e4..339f8fb9f 100644 --- a/packages/sdk/src/models/list-entities-op.ts +++ b/packages/sdk/src/models/list-entities-op.ts @@ -211,15 +211,16 @@ export type ListEntitiesPurchase = { }; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export const ListEntitiesType = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export type ListEntitiesType = OpenEnum; @@ -234,6 +235,16 @@ export type ListEntitiesCreditSchema = { creditCost: number; }; +export type ListEntitiesModelMarkups = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type ListEntitiesProviderMarkups = { + markup: number; +}; + /** * Display names for the feature in billing UI and customer-facing components. */ @@ -261,7 +272,7 @@ export type ListEntitiesFeature = { */ name: string; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ type: ListEntitiesType; /** @@ -276,6 +287,21 @@ export type ListEntitiesFeature = { * For credit_system features: maps metered features to their credit costs. */ creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. + */ + modelMarkups?: { [k: string]: ListEntitiesModelMarkups } | null | undefined; + /** + * Default percentage markup for AI credit systems. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. + */ + providerMarkups?: + | { [k: string]: ListEntitiesProviderMarkups } + | null + | undefined; /** * Display names for the feature in billing UI and customer-facing components. */ @@ -708,6 +734,52 @@ export function listEntitiesCreditSchemaFromJSON( ); } +/** @internal */ +export const ListEntitiesModelMarkups$inboundSchema: z.ZodMiniType< + ListEntitiesModelMarkups, + unknown +> = z.pipe( + z.object({ + markup: types.optional(types.number()), + input_cost: types.optional(types.number()), + output_cost: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "input_cost": "inputCost", + "output_cost": "outputCost", + }); + }), +); + +export function listEntitiesModelMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => ListEntitiesModelMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'ListEntitiesModelMarkups' from JSON`, + ); +} + +/** @internal */ +export const ListEntitiesProviderMarkups$inboundSchema: z.ZodMiniType< + ListEntitiesProviderMarkups, + unknown +> = z.object({ + markup: types.number(), +}); + +export function listEntitiesProviderMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => ListEntitiesProviderMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'ListEntitiesProviderMarkups' from JSON`, + ); +} + /** @internal */ export const ListEntitiesDisplay$inboundSchema: z.ZodMiniType< ListEntitiesDisplay, @@ -741,13 +813,27 @@ export const ListEntitiesFeature$inboundSchema: z.ZodMiniType< credit_schema: types.optional( z.array(z.lazy(() => ListEntitiesCreditSchema$inboundSchema)), ), - display: types.optional(z.lazy(() => ListEntitiesDisplay$inboundSchema)), + model_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => ListEntitiesModelMarkups$inboundSchema), + ))), + default_markup: types.optional(types.number()), + provider_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => ListEntitiesProviderMarkups$inboundSchema), + ))), + display: types.optional(z.lazy(() => + ListEntitiesDisplay$inboundSchema + )), archived: types.boolean(), }), z.transform((v) => { return remap$(v, { "event_names": "eventNames", "credit_schema": "creditSchema", + "model_markups": "modelMarkups", + "default_markup": "defaultMarkup", + "provider_markups": "providerMarkups", }); }), ); diff --git a/packages/sdk/src/models/list-features-op.ts b/packages/sdk/src/models/list-features-op.ts index 0f260a39a..cc0e37a19 100644 --- a/packages/sdk/src/models/list-features-op.ts +++ b/packages/sdk/src/models/list-features-op.ts @@ -18,15 +18,16 @@ export type ListFeaturesGlobals = { export type ListFeaturesRequest = {}; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export const ListFeaturesType = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export type ListFeaturesType = OpenEnum; @@ -41,6 +42,16 @@ export type ListFeaturesCreditSchema = { creditCost: number; }; +export type ListFeaturesModelMarkups = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type ListFeaturesProviderMarkups = { + markup: number; +}; + /** * Display names for the feature in billing UI and customer-facing components. */ @@ -65,7 +76,7 @@ export type ListFeaturesList = { */ name: string; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ type: ListFeaturesType; /** @@ -80,6 +91,21 @@ export type ListFeaturesList = { * For credit_system features: maps metered features to their credit costs. */ creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. + */ + modelMarkups?: { [k: string]: ListFeaturesModelMarkups } | null | undefined; + /** + * Default percentage markup for AI credit systems. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. + */ + providerMarkups?: + | { [k: string]: ListFeaturesProviderMarkups } + | null + | undefined; /** * Display names for the feature in billing UI and customer-facing components. */ @@ -147,6 +173,52 @@ export function listFeaturesCreditSchemaFromJSON( ); } +/** @internal */ +export const ListFeaturesModelMarkups$inboundSchema: z.ZodMiniType< + ListFeaturesModelMarkups, + unknown +> = z.pipe( + z.object({ + markup: types.optional(types.number()), + input_cost: types.optional(types.number()), + output_cost: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "input_cost": "inputCost", + "output_cost": "outputCost", + }); + }), +); + +export function listFeaturesModelMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => ListFeaturesModelMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'ListFeaturesModelMarkups' from JSON`, + ); +} + +/** @internal */ +export const ListFeaturesProviderMarkups$inboundSchema: z.ZodMiniType< + ListFeaturesProviderMarkups, + unknown +> = z.object({ + markup: types.number(), +}); + +export function listFeaturesProviderMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => ListFeaturesProviderMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'ListFeaturesProviderMarkups' from JSON`, + ); +} + /** @internal */ export const ListFeaturesDisplay$inboundSchema: z.ZodMiniType< ListFeaturesDisplay, @@ -180,13 +252,27 @@ export const ListFeaturesList$inboundSchema: z.ZodMiniType< credit_schema: types.optional( z.array(z.lazy(() => ListFeaturesCreditSchema$inboundSchema)), ), - display: types.optional(z.lazy(() => ListFeaturesDisplay$inboundSchema)), + model_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => ListFeaturesModelMarkups$inboundSchema), + ))), + default_markup: types.optional(types.number()), + provider_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => ListFeaturesProviderMarkups$inboundSchema), + ))), + display: types.optional(z.lazy(() => + ListFeaturesDisplay$inboundSchema + )), archived: types.boolean(), }), z.transform((v) => { return remap$(v, { "event_names": "eventNames", "credit_schema": "creditSchema", + "model_markups": "modelMarkups", + "default_markup": "defaultMarkup", + "provider_markups": "providerMarkups", }); }), ); diff --git a/packages/sdk/src/models/list-plans-op.ts b/packages/sdk/src/models/list-plans-op.ts index bd00d1a76..fb4a679c3 100644 --- a/packages/sdk/src/models/list-plans-op.ts +++ b/packages/sdk/src/models/list-plans-op.ts @@ -9,6 +9,7 @@ import * as openEnums from "../types/enums.js"; import { OpenEnum } from "../types/enums.js"; import { Result as SafeParseResult } from "../types/fp.js"; import * as types from "../types/primitives.js"; +import { smartUnion } from "../types/smart-union.js"; import { SDKValidationError } from "./sdk-validation-error.js"; export type ListPlansGlobals = { @@ -88,6 +89,7 @@ export const ListPlansType = { SingleUse: "single_use", ContinuousUse: "continuous_use", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** * The type of the feature @@ -176,6 +178,14 @@ export type ListPlansReset = { intervalCount?: number | undefined; }; +export type ListPlansTo = number | string; + +export type ListPlansTier = { + to: number | string; + amount: number; + flatAmount?: number | undefined; +}; + export const ListPlansTierBehavior = { Graduated: "graduated", Volume: "volume", @@ -220,7 +230,7 @@ export type ListPlansItemPrice = { /** * Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. */ - tiers?: Array | undefined; + tiers?: Array | undefined; tierBehavior?: ListPlansTierBehavior | undefined; /** * Billing interval for this price. For consumable features, should match reset.interval. @@ -727,6 +737,47 @@ export function listPlansResetFromJSON( ); } +/** @internal */ +export const ListPlansTo$inboundSchema: z.ZodMiniType = + smartUnion([types.number(), types.string()]); + +export function listPlansToFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => ListPlansTo$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'ListPlansTo' from JSON`, + ); +} + +/** @internal */ +export const ListPlansTier$inboundSchema: z.ZodMiniType< + ListPlansTier, + unknown +> = z.pipe( + z.object({ + to: smartUnion([types.number(), types.string()]), + amount: types.number(), + flat_amount: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "flat_amount": "flatAmount", + }); + }), +); + +export function listPlansTierFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => ListPlansTier$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'ListPlansTier' from JSON`, + ); +} + /** @internal */ export const ListPlansTierBehavior$inboundSchema: z.ZodMiniType< ListPlansTierBehavior, @@ -752,7 +803,7 @@ export const ListPlansItemPrice$inboundSchema: z.ZodMiniType< > = z.pipe( z.object({ amount: types.optional(types.number()), - tiers: types.optional(z.array(types.nullable(z.any()))), + tiers: types.optional(z.array(z.lazy(() => ListPlansTier$inboundSchema))), tier_behavior: types.optional(ListPlansTierBehavior$inboundSchema), interval: ListPlansPriceItemInterval$inboundSchema, interval_count: types.optional(types.number()), diff --git a/packages/sdk/src/models/multi-attach-op.ts b/packages/sdk/src/models/multi-attach-op.ts index 38d6d3417..492cd0180 100644 --- a/packages/sdk/src/models/multi-attach-op.ts +++ b/packages/sdk/src/models/multi-attach-op.ts @@ -170,9 +170,9 @@ export type MultiAttachPrice = { */ billingMethod: MultiAttachBillingMethod; /** - * 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. */ - maxPurchase?: number | undefined; + maxPurchase?: number | null | undefined; }; /** @@ -815,7 +815,7 @@ export type MultiAttachPrice$Outbound = { interval_count: number; billing_units: number; billing_method: string; - max_purchase?: number | undefined; + max_purchase?: number | null | undefined; }; /** @internal */ @@ -831,7 +831,7 @@ export const MultiAttachPrice$outboundSchema: z.ZodMiniType< intervalCount: z._default(z.number(), 1), billingUnits: z._default(z.number(), 1), billingMethod: MultiAttachBillingMethod$outboundSchema, - maxPurchase: z.optional(z.number()), + maxPurchase: z.optional(z.nullable(z.number())), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/plan.ts b/packages/sdk/src/models/plan.ts index edba9d825..94a0efba5 100644 --- a/packages/sdk/src/models/plan.ts +++ b/packages/sdk/src/models/plan.ts @@ -9,6 +9,7 @@ import * as openEnums from "../types/enums.js"; import { OpenEnum } from "../types/enums.js"; import { Result as SafeParseResult } from "../types/fp.js"; import * as types from "../types/primitives.js"; +import { smartUnion } from "../types/smart-union.js"; import { SDKValidationError } from "./sdk-validation-error.js"; /** @@ -69,6 +70,7 @@ export const PlanType = { SingleUse: "single_use", ContinuousUse: "continuous_use", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** * The type of the feature @@ -157,6 +159,14 @@ export type PlanReset = { intervalCount?: number | undefined; }; +export type PlanTo = number | string; + +export type PlanTier = { + to: number | string; + amount: number; + flatAmount?: number | undefined; +}; + export const PlanTierBehavior = { Graduated: "graduated", Volume: "volume", @@ -199,7 +209,7 @@ export type PlanItemPrice = { /** * Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. */ - tiers?: Array | undefined; + tiers?: Array | undefined; tierBehavior?: PlanTierBehavior | undefined; /** * Billing interval for this price. For consumable features, should match reset.interval. @@ -654,6 +664,46 @@ export function planResetFromJSON( ); } +/** @internal */ +export const PlanTo$inboundSchema: z.ZodMiniType = smartUnion([ + types.number(), + types.string(), +]); + +export function planToFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => PlanTo$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'PlanTo' from JSON`, + ); +} + +/** @internal */ +export const PlanTier$inboundSchema: z.ZodMiniType = z.pipe( + z.object({ + to: smartUnion([types.number(), types.string()]), + amount: types.number(), + flat_amount: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "flat_amount": "flatAmount", + }); + }), +); + +export function planTierFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => PlanTier$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'PlanTier' from JSON`, + ); +} + /** @internal */ export const PlanTierBehavior$inboundSchema: z.ZodMiniType< PlanTierBehavior, @@ -679,7 +729,7 @@ export const PlanItemPrice$inboundSchema: z.ZodMiniType< > = z.pipe( z.object({ amount: types.optional(types.number()), - tiers: types.optional(z.array(types.nullable(z.any()))), + tiers: types.optional(z.array(z.lazy(() => PlanTier$inboundSchema))), tier_behavior: types.optional(PlanTierBehavior$inboundSchema), interval: PlanPriceItemInterval$inboundSchema, interval_count: types.optional(types.number()), diff --git a/packages/sdk/src/models/preview-attach-op.ts b/packages/sdk/src/models/preview-attach-op.ts index 969faf47c..e02113f33 100644 --- a/packages/sdk/src/models/preview-attach-op.ts +++ b/packages/sdk/src/models/preview-attach-op.ts @@ -184,9 +184,9 @@ export type PreviewAttachItemPrice = { */ billingMethod: PreviewAttachItemBillingMethod; /** - * 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. */ - maxPurchase?: number | undefined; + maxPurchase?: number | null | undefined; }; /** @@ -419,9 +419,9 @@ export type PreviewAttachAddItemPrice = { */ billingMethod: PreviewAttachAddItemBillingMethod; /** - * 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. */ - maxPurchase?: number | undefined; + maxPurchase?: number | null | undefined; }; /** @@ -555,10 +555,22 @@ export type PreviewAttachRemoveItemBillingMethod = ClosedEnum< typeof PreviewAttachRemoveItemBillingMethod >; -/** - * Match items with this interval. - */ -export const PreviewAttachRemoveItemInterval = { +export const PreviewAttachIntervalRemoveItemEnum2 = { + OneOff: "one_off", + Minute: "minute", + Hour: "hour", + Day: "day", + Week: "week", + Month: "month", + Quarter: "quarter", + SemiAnnual: "semi_annual", + Year: "year", +} as const; +export type PreviewAttachIntervalRemoveItemEnum2 = ClosedEnum< + typeof PreviewAttachIntervalRemoveItemEnum2 +>; + +export const PreviewAttachIntervalRemoveItemEnum1 = { OneOff: "one_off", Week: "week", Month: "month", @@ -566,13 +578,17 @@ export const PreviewAttachRemoveItemInterval = { SemiAnnual: "semi_annual", Year: "year", } as const; -/** - * Match items with this interval. - */ -export type PreviewAttachRemoveItemInterval = ClosedEnum< - typeof PreviewAttachRemoveItemInterval +export type PreviewAttachIntervalRemoveItemEnum1 = ClosedEnum< + typeof PreviewAttachIntervalRemoveItemEnum1 >; +/** + * 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. + */ +export type PreviewAttachIntervalUnion = + | PreviewAttachIntervalRemoveItemEnum1 + | PreviewAttachIntervalRemoveItemEnum2; + /** * Filter for matching plan items. All provided fields must match (AND). */ @@ -586,9 +602,16 @@ export type PreviewAttachPlanItemFilter = { */ billingMethod?: PreviewAttachRemoveItemBillingMethod | undefined; /** - * Match items with this interval. + * 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?: PreviewAttachRemoveItemInterval | undefined; + interval?: + | PreviewAttachIntervalRemoveItemEnum1 + | PreviewAttachIntervalRemoveItemEnum2 + | undefined; + /** + * Match items with this interval_count. Disambiguates between items that share an interval but differ in count. + */ + intervalCount?: number | undefined; }; /** @@ -649,7 +672,7 @@ export type PreviewAttachCustomize = { */ price?: PreviewAttachBasePrice | null | undefined; /** - * Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items. + * Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items. */ items?: Array | undefined; /** @@ -1438,7 +1461,7 @@ export type PreviewAttachItemPrice$Outbound = { interval_count: number; billing_units: number; billing_method: string; - max_purchase?: number | undefined; + max_purchase?: number | null | undefined; }; /** @internal */ @@ -1456,7 +1479,7 @@ export const PreviewAttachItemPrice$outboundSchema: z.ZodMiniType< intervalCount: z._default(z.number(), 1), billingUnits: z._default(z.number(), 1), billingMethod: PreviewAttachItemBillingMethod$outboundSchema, - maxPurchase: z.optional(z.number()), + maxPurchase: z.optional(z.nullable(z.number())), }), z.transform((v) => { return remap$(v, { @@ -1711,7 +1734,7 @@ export type PreviewAttachAddItemPrice$Outbound = { interval_count: number; billing_units: number; billing_method: string; - max_purchase?: number | undefined; + max_purchase?: number | null | undefined; }; /** @internal */ @@ -1729,7 +1752,7 @@ export const PreviewAttachAddItemPrice$outboundSchema: z.ZodMiniType< intervalCount: z._default(z.number(), 1), billingUnits: z._default(z.number(), 1), billingMethod: PreviewAttachAddItemBillingMethod$outboundSchema, - maxPurchase: z.optional(z.number()), + maxPurchase: z.optional(z.nullable(z.number())), }), z.transform((v) => { return remap$(v, { @@ -1889,15 +1912,41 @@ export const PreviewAttachRemoveItemBillingMethod$outboundSchema: z.ZodMiniEnum< > = z.enum(PreviewAttachRemoveItemBillingMethod); /** @internal */ -export const PreviewAttachRemoveItemInterval$outboundSchema: z.ZodMiniEnum< - typeof PreviewAttachRemoveItemInterval -> = z.enum(PreviewAttachRemoveItemInterval); +export const PreviewAttachIntervalRemoveItemEnum2$outboundSchema: z.ZodMiniEnum< + typeof PreviewAttachIntervalRemoveItemEnum2 +> = z.enum(PreviewAttachIntervalRemoveItemEnum2); + +/** @internal */ +export const PreviewAttachIntervalRemoveItemEnum1$outboundSchema: z.ZodMiniEnum< + typeof PreviewAttachIntervalRemoveItemEnum1 +> = z.enum(PreviewAttachIntervalRemoveItemEnum1); + +/** @internal */ +export type PreviewAttachIntervalUnion$Outbound = string | string; + +/** @internal */ +export const PreviewAttachIntervalUnion$outboundSchema: z.ZodMiniType< + PreviewAttachIntervalUnion$Outbound, + PreviewAttachIntervalUnion +> = smartUnion([ + PreviewAttachIntervalRemoveItemEnum1$outboundSchema, + PreviewAttachIntervalRemoveItemEnum2$outboundSchema, +]); + +export function previewAttachIntervalUnionToJSON( + previewAttachIntervalUnion: PreviewAttachIntervalUnion, +): string { + return JSON.stringify( + PreviewAttachIntervalUnion$outboundSchema.parse(previewAttachIntervalUnion), + ); +} /** @internal */ export type PreviewAttachPlanItemFilter$Outbound = { feature_id?: string | undefined; billing_method?: string | undefined; - interval?: string | undefined; + interval?: string | string | undefined; + interval_count?: number | undefined; }; /** @internal */ @@ -1910,12 +1959,19 @@ export const PreviewAttachPlanItemFilter$outboundSchema: z.ZodMiniType< billingMethod: z.optional( PreviewAttachRemoveItemBillingMethod$outboundSchema, ), - interval: z.optional(PreviewAttachRemoveItemInterval$outboundSchema), + interval: z.optional( + smartUnion([ + PreviewAttachIntervalRemoveItemEnum1$outboundSchema, + PreviewAttachIntervalRemoveItemEnum2$outboundSchema, + ]), + ), + intervalCount: z.optional(z.int()), }), z.transform((v) => { return remap$(v, { featureId: "feature_id", billingMethod: "billing_method", + intervalCount: "interval_count", }); }), ); diff --git a/packages/sdk/src/models/preview-multi-attach-op.ts b/packages/sdk/src/models/preview-multi-attach-op.ts index a0be451fa..7edc30bef 100644 --- a/packages/sdk/src/models/preview-multi-attach-op.ts +++ b/packages/sdk/src/models/preview-multi-attach-op.ts @@ -171,9 +171,9 @@ export type PreviewMultiAttachPrice = { */ billingMethod: PreviewMultiAttachBillingMethod; /** - * 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. */ - maxPurchase?: number | undefined; + maxPurchase?: number | null | undefined; }; /** @@ -1117,7 +1117,7 @@ export type PreviewMultiAttachPrice$Outbound = { interval_count: number; billing_units: number; billing_method: string; - max_purchase?: number | undefined; + max_purchase?: number | null | undefined; }; /** @internal */ @@ -1135,7 +1135,7 @@ export const PreviewMultiAttachPrice$outboundSchema: z.ZodMiniType< intervalCount: z._default(z.number(), 1), billingUnits: z._default(z.number(), 1), billingMethod: PreviewMultiAttachBillingMethod$outboundSchema, - maxPurchase: z.optional(z.number()), + maxPurchase: z.optional(z.nullable(z.number())), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/preview-update-op.ts b/packages/sdk/src/models/preview-update-op.ts index 279424e24..61c8c3978 100644 --- a/packages/sdk/src/models/preview-update-op.ts +++ b/packages/sdk/src/models/preview-update-op.ts @@ -184,9 +184,9 @@ export type PreviewUpdateItemPrice = { */ billingMethod: PreviewUpdateItemBillingMethod; /** - * 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. */ - maxPurchase?: number | undefined; + maxPurchase?: number | null | undefined; }; /** @@ -419,9 +419,9 @@ export type PreviewUpdateAddItemPrice = { */ billingMethod: PreviewUpdateAddItemBillingMethod; /** - * 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. */ - maxPurchase?: number | undefined; + maxPurchase?: number | null | undefined; }; /** @@ -555,10 +555,22 @@ export type PreviewUpdateRemoveItemBillingMethod = ClosedEnum< typeof PreviewUpdateRemoveItemBillingMethod >; -/** - * Match items with this interval. - */ -export const PreviewUpdateRemoveItemInterval = { +export const PreviewUpdateIntervalRemoveItemEnum2 = { + OneOff: "one_off", + Minute: "minute", + Hour: "hour", + Day: "day", + Week: "week", + Month: "month", + Quarter: "quarter", + SemiAnnual: "semi_annual", + Year: "year", +} as const; +export type PreviewUpdateIntervalRemoveItemEnum2 = ClosedEnum< + typeof PreviewUpdateIntervalRemoveItemEnum2 +>; + +export const PreviewUpdateIntervalRemoveItemEnum1 = { OneOff: "one_off", Week: "week", Month: "month", @@ -566,13 +578,17 @@ export const PreviewUpdateRemoveItemInterval = { SemiAnnual: "semi_annual", Year: "year", } as const; -/** - * Match items with this interval. - */ -export type PreviewUpdateRemoveItemInterval = ClosedEnum< - typeof PreviewUpdateRemoveItemInterval +export type PreviewUpdateIntervalRemoveItemEnum1 = ClosedEnum< + typeof PreviewUpdateIntervalRemoveItemEnum1 >; +/** + * 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. + */ +export type PreviewUpdateIntervalUnion = + | PreviewUpdateIntervalRemoveItemEnum1 + | PreviewUpdateIntervalRemoveItemEnum2; + /** * Filter for matching plan items. All provided fields must match (AND). */ @@ -586,9 +602,16 @@ export type PreviewUpdatePlanItemFilter = { */ billingMethod?: PreviewUpdateRemoveItemBillingMethod | undefined; /** - * Match items with this interval. + * 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?: PreviewUpdateRemoveItemInterval | undefined; + interval?: + | PreviewUpdateIntervalRemoveItemEnum1 + | PreviewUpdateIntervalRemoveItemEnum2 + | undefined; + /** + * Match items with this interval_count. Disambiguates between items that share an interval but differ in count. + */ + intervalCount?: number | undefined; }; /** @@ -649,7 +672,7 @@ export type PreviewUpdateCustomize = { */ price?: PreviewUpdateBasePrice | null | undefined; /** - * Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items. + * Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items. */ items?: Array | undefined; /** @@ -1364,7 +1387,7 @@ export type PreviewUpdateItemPrice$Outbound = { interval_count: number; billing_units: number; billing_method: string; - max_purchase?: number | undefined; + max_purchase?: number | null | undefined; }; /** @internal */ @@ -1382,7 +1405,7 @@ export const PreviewUpdateItemPrice$outboundSchema: z.ZodMiniType< intervalCount: z._default(z.number(), 1), billingUnits: z._default(z.number(), 1), billingMethod: PreviewUpdateItemBillingMethod$outboundSchema, - maxPurchase: z.optional(z.number()), + maxPurchase: z.optional(z.nullable(z.number())), }), z.transform((v) => { return remap$(v, { @@ -1637,7 +1660,7 @@ export type PreviewUpdateAddItemPrice$Outbound = { interval_count: number; billing_units: number; billing_method: string; - max_purchase?: number | undefined; + max_purchase?: number | null | undefined; }; /** @internal */ @@ -1655,7 +1678,7 @@ export const PreviewUpdateAddItemPrice$outboundSchema: z.ZodMiniType< intervalCount: z._default(z.number(), 1), billingUnits: z._default(z.number(), 1), billingMethod: PreviewUpdateAddItemBillingMethod$outboundSchema, - maxPurchase: z.optional(z.number()), + maxPurchase: z.optional(z.nullable(z.number())), }), z.transform((v) => { return remap$(v, { @@ -1815,15 +1838,41 @@ export const PreviewUpdateRemoveItemBillingMethod$outboundSchema: z.ZodMiniEnum< > = z.enum(PreviewUpdateRemoveItemBillingMethod); /** @internal */ -export const PreviewUpdateRemoveItemInterval$outboundSchema: z.ZodMiniEnum< - typeof PreviewUpdateRemoveItemInterval -> = z.enum(PreviewUpdateRemoveItemInterval); +export const PreviewUpdateIntervalRemoveItemEnum2$outboundSchema: z.ZodMiniEnum< + typeof PreviewUpdateIntervalRemoveItemEnum2 +> = z.enum(PreviewUpdateIntervalRemoveItemEnum2); + +/** @internal */ +export const PreviewUpdateIntervalRemoveItemEnum1$outboundSchema: z.ZodMiniEnum< + typeof PreviewUpdateIntervalRemoveItemEnum1 +> = z.enum(PreviewUpdateIntervalRemoveItemEnum1); + +/** @internal */ +export type PreviewUpdateIntervalUnion$Outbound = string | string; + +/** @internal */ +export const PreviewUpdateIntervalUnion$outboundSchema: z.ZodMiniType< + PreviewUpdateIntervalUnion$Outbound, + PreviewUpdateIntervalUnion +> = smartUnion([ + PreviewUpdateIntervalRemoveItemEnum1$outboundSchema, + PreviewUpdateIntervalRemoveItemEnum2$outboundSchema, +]); + +export function previewUpdateIntervalUnionToJSON( + previewUpdateIntervalUnion: PreviewUpdateIntervalUnion, +): string { + return JSON.stringify( + PreviewUpdateIntervalUnion$outboundSchema.parse(previewUpdateIntervalUnion), + ); +} /** @internal */ export type PreviewUpdatePlanItemFilter$Outbound = { feature_id?: string | undefined; billing_method?: string | undefined; - interval?: string | undefined; + interval?: string | string | undefined; + interval_count?: number | undefined; }; /** @internal */ @@ -1836,12 +1885,19 @@ export const PreviewUpdatePlanItemFilter$outboundSchema: z.ZodMiniType< billingMethod: z.optional( PreviewUpdateRemoveItemBillingMethod$outboundSchema, ), - interval: z.optional(PreviewUpdateRemoveItemInterval$outboundSchema), + interval: z.optional( + smartUnion([ + PreviewUpdateIntervalRemoveItemEnum1$outboundSchema, + PreviewUpdateIntervalRemoveItemEnum2$outboundSchema, + ]), + ), + intervalCount: z.optional(z.int()), }), z.transform((v) => { return remap$(v, { featureId: "feature_id", billingMethod: "billing_method", + intervalCount: "interval_count", }); }), ); diff --git a/packages/sdk/src/models/setup-payment-op.ts b/packages/sdk/src/models/setup-payment-op.ts index 888b6921e..d5b502e7a 100644 --- a/packages/sdk/src/models/setup-payment-op.ts +++ b/packages/sdk/src/models/setup-payment-op.ts @@ -182,9 +182,9 @@ export type SetupPaymentItemPrice = { */ billingMethod: SetupPaymentItemBillingMethod; /** - * 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. */ - maxPurchase?: number | undefined; + maxPurchase?: number | null | undefined; }; /** @@ -417,9 +417,9 @@ export type SetupPaymentAddItemPrice = { */ billingMethod: SetupPaymentAddItemBillingMethod; /** - * 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. */ - maxPurchase?: number | undefined; + maxPurchase?: number | null | undefined; }; /** @@ -553,10 +553,22 @@ export type SetupPaymentRemoveItemBillingMethod = ClosedEnum< typeof SetupPaymentRemoveItemBillingMethod >; -/** - * Match items with this interval. - */ -export const SetupPaymentRemoveItemInterval = { +export const SetupPaymentIntervalRemoveItemEnum2 = { + OneOff: "one_off", + Minute: "minute", + Hour: "hour", + Day: "day", + Week: "week", + Month: "month", + Quarter: "quarter", + SemiAnnual: "semi_annual", + Year: "year", +} as const; +export type SetupPaymentIntervalRemoveItemEnum2 = ClosedEnum< + typeof SetupPaymentIntervalRemoveItemEnum2 +>; + +export const SetupPaymentIntervalRemoveItemEnum1 = { OneOff: "one_off", Week: "week", Month: "month", @@ -564,13 +576,17 @@ export const SetupPaymentRemoveItemInterval = { SemiAnnual: "semi_annual", Year: "year", } as const; -/** - * Match items with this interval. - */ -export type SetupPaymentRemoveItemInterval = ClosedEnum< - typeof SetupPaymentRemoveItemInterval +export type SetupPaymentIntervalRemoveItemEnum1 = ClosedEnum< + typeof SetupPaymentIntervalRemoveItemEnum1 >; +/** + * 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. + */ +export type SetupPaymentIntervalUnion = + | SetupPaymentIntervalRemoveItemEnum1 + | SetupPaymentIntervalRemoveItemEnum2; + /** * Filter for matching plan items. All provided fields must match (AND). */ @@ -584,9 +600,16 @@ export type SetupPaymentPlanItemFilter = { */ billingMethod?: SetupPaymentRemoveItemBillingMethod | undefined; /** - * Match items with this interval. + * 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?: SetupPaymentRemoveItemInterval | undefined; + interval?: + | SetupPaymentIntervalRemoveItemEnum1 + | SetupPaymentIntervalRemoveItemEnum2 + | undefined; + /** + * Match items with this interval_count. Disambiguates between items that share an interval but differ in count. + */ + intervalCount?: number | undefined; }; /** @@ -647,7 +670,7 @@ export type SetupPaymentCustomize = { */ price?: SetupPaymentBasePrice | null | undefined; /** - * Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items. + * Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items. */ items?: Array | undefined; /** @@ -1019,7 +1042,7 @@ export type SetupPaymentItemPrice$Outbound = { interval_count: number; billing_units: number; billing_method: string; - max_purchase?: number | undefined; + max_purchase?: number | null | undefined; }; /** @internal */ @@ -1037,7 +1060,7 @@ export const SetupPaymentItemPrice$outboundSchema: z.ZodMiniType< intervalCount: z._default(z.number(), 1), billingUnits: z._default(z.number(), 1), billingMethod: SetupPaymentItemBillingMethod$outboundSchema, - maxPurchase: z.optional(z.number()), + maxPurchase: z.optional(z.nullable(z.number())), }), z.transform((v) => { return remap$(v, { @@ -1290,7 +1313,7 @@ export type SetupPaymentAddItemPrice$Outbound = { interval_count: number; billing_units: number; billing_method: string; - max_purchase?: number | undefined; + max_purchase?: number | null | undefined; }; /** @internal */ @@ -1308,7 +1331,7 @@ export const SetupPaymentAddItemPrice$outboundSchema: z.ZodMiniType< intervalCount: z._default(z.number(), 1), billingUnits: z._default(z.number(), 1), billingMethod: SetupPaymentAddItemBillingMethod$outboundSchema, - maxPurchase: z.optional(z.number()), + maxPurchase: z.optional(z.nullable(z.number())), }), z.transform((v) => { return remap$(v, { @@ -1468,15 +1491,41 @@ export const SetupPaymentRemoveItemBillingMethod$outboundSchema: z.ZodMiniEnum< > = z.enum(SetupPaymentRemoveItemBillingMethod); /** @internal */ -export const SetupPaymentRemoveItemInterval$outboundSchema: z.ZodMiniEnum< - typeof SetupPaymentRemoveItemInterval -> = z.enum(SetupPaymentRemoveItemInterval); +export const SetupPaymentIntervalRemoveItemEnum2$outboundSchema: z.ZodMiniEnum< + typeof SetupPaymentIntervalRemoveItemEnum2 +> = z.enum(SetupPaymentIntervalRemoveItemEnum2); + +/** @internal */ +export const SetupPaymentIntervalRemoveItemEnum1$outboundSchema: z.ZodMiniEnum< + typeof SetupPaymentIntervalRemoveItemEnum1 +> = z.enum(SetupPaymentIntervalRemoveItemEnum1); + +/** @internal */ +export type SetupPaymentIntervalUnion$Outbound = string | string; + +/** @internal */ +export const SetupPaymentIntervalUnion$outboundSchema: z.ZodMiniType< + SetupPaymentIntervalUnion$Outbound, + SetupPaymentIntervalUnion +> = smartUnion([ + SetupPaymentIntervalRemoveItemEnum1$outboundSchema, + SetupPaymentIntervalRemoveItemEnum2$outboundSchema, +]); + +export function setupPaymentIntervalUnionToJSON( + setupPaymentIntervalUnion: SetupPaymentIntervalUnion, +): string { + return JSON.stringify( + SetupPaymentIntervalUnion$outboundSchema.parse(setupPaymentIntervalUnion), + ); +} /** @internal */ export type SetupPaymentPlanItemFilter$Outbound = { feature_id?: string | undefined; billing_method?: string | undefined; - interval?: string | undefined; + interval?: string | string | undefined; + interval_count?: number | undefined; }; /** @internal */ @@ -1489,12 +1538,19 @@ export const SetupPaymentPlanItemFilter$outboundSchema: z.ZodMiniType< billingMethod: z.optional( SetupPaymentRemoveItemBillingMethod$outboundSchema, ), - interval: z.optional(SetupPaymentRemoveItemInterval$outboundSchema), + interval: z.optional( + smartUnion([ + SetupPaymentIntervalRemoveItemEnum1$outboundSchema, + SetupPaymentIntervalRemoveItemEnum2$outboundSchema, + ]), + ), + intervalCount: z.optional(z.int()), }), z.transform((v) => { return remap$(v, { featureId: "feature_id", billingMethod: "billing_method", + intervalCount: "interval_count", }); }), ); diff --git a/packages/sdk/src/models/track-op.ts b/packages/sdk/src/models/track-op.ts index 8efd488c0..36e442be7 100644 --- a/packages/sdk/src/models/track-op.ts +++ b/packages/sdk/src/models/track-op.ts @@ -97,7 +97,7 @@ export type TrackReset2 = { resetsAt: number | null; }; -export type Deduction2 = { +export type TrackDeduction2 = { /** * ID of the underlying balance row that was deducted from (customer_entitlement or rollover). */ @@ -151,7 +151,7 @@ export type TrackResponseBody2 = { /** * Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling. */ - deductions?: Array | undefined; + deductions?: Array | undefined; }; export const TrackIntervalEnum1 = { @@ -187,7 +187,7 @@ export type TrackReset1 = { resetsAt: number | null; }; -export type Deduction1 = { +export type TrackDeduction1 = { /** * ID of the underlying balance row that was deducted from (customer_entitlement or rollover). */ @@ -241,7 +241,7 @@ export type TrackResponseBody1 = { /** * Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling. */ - deductions?: Array | undefined; + deductions?: Array | undefined; }; export type TrackResponse = TrackResponseBody1 | TrackResponseBody2; @@ -365,31 +365,33 @@ export function trackReset2FromJSON( } /** @internal */ -export const Deduction2$inboundSchema: z.ZodMiniType = z - .pipe( - z.object({ - balance_id: types.string(), - feature_id: types.string(), - plan_id: types.nullable(types.string()), - reset: types.nullable(z.lazy(() => TrackReset2$inboundSchema)), - value: types.number(), - }), - z.transform((v) => { - return remap$(v, { - "balance_id": "balanceId", - "feature_id": "featureId", - "plan_id": "planId", - }); - }), - ); +export const TrackDeduction2$inboundSchema: z.ZodMiniType< + TrackDeduction2, + unknown +> = z.pipe( + z.object({ + balance_id: types.string(), + feature_id: types.string(), + plan_id: types.nullable(types.string()), + reset: types.nullable(z.lazy(() => TrackReset2$inboundSchema)), + value: types.number(), + }), + z.transform((v) => { + return remap$(v, { + "balance_id": "balanceId", + "feature_id": "featureId", + "plan_id": "planId", + }); + }), +); -export function deduction2FromJSON( +export function trackDeduction2FromJSON( jsonString: string, -): SafeParseResult { +): SafeParseResult { return safeParse( jsonString, - (x) => Deduction2$inboundSchema.parse(JSON.parse(x)), - `Failed to parse 'Deduction2' from JSON`, + (x) => TrackDeduction2$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'TrackDeduction2' from JSON`, ); } @@ -407,7 +409,9 @@ export const TrackResponseBody2$inboundSchema: z.ZodMiniType< balances: types.optional( z.record(z.string(), types.nullable(Balance$inboundSchema)), ), - deductions: types.optional(z.array(z.lazy(() => Deduction2$inboundSchema))), + deductions: types.optional( + z.array(z.lazy(() => TrackDeduction2$inboundSchema)), + ), }), z.transform((v) => { return remap$(v, { @@ -477,31 +481,33 @@ export function trackReset1FromJSON( } /** @internal */ -export const Deduction1$inboundSchema: z.ZodMiniType = z - .pipe( - z.object({ - balance_id: types.string(), - feature_id: types.string(), - plan_id: types.nullable(types.string()), - reset: types.nullable(z.lazy(() => TrackReset1$inboundSchema)), - value: types.number(), - }), - z.transform((v) => { - return remap$(v, { - "balance_id": "balanceId", - "feature_id": "featureId", - "plan_id": "planId", - }); - }), - ); +export const TrackDeduction1$inboundSchema: z.ZodMiniType< + TrackDeduction1, + unknown +> = z.pipe( + z.object({ + balance_id: types.string(), + feature_id: types.string(), + plan_id: types.nullable(types.string()), + reset: types.nullable(z.lazy(() => TrackReset1$inboundSchema)), + value: types.number(), + }), + z.transform((v) => { + return remap$(v, { + "balance_id": "balanceId", + "feature_id": "featureId", + "plan_id": "planId", + }); + }), +); -export function deduction1FromJSON( +export function trackDeduction1FromJSON( jsonString: string, -): SafeParseResult { +): SafeParseResult { return safeParse( jsonString, - (x) => Deduction1$inboundSchema.parse(JSON.parse(x)), - `Failed to parse 'Deduction1' from JSON`, + (x) => TrackDeduction1$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'TrackDeduction1' from JSON`, ); } @@ -519,7 +525,9 @@ export const TrackResponseBody1$inboundSchema: z.ZodMiniType< balances: types.optional( z.record(z.string(), types.nullable(Balance$inboundSchema)), ), - deductions: types.optional(z.array(z.lazy(() => Deduction1$inboundSchema))), + deductions: types.optional( + z.array(z.lazy(() => TrackDeduction1$inboundSchema)), + ), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/track-tokens-op.ts b/packages/sdk/src/models/track-tokens-op.ts new file mode 100644 index 000000000..4f5be60e5 --- /dev/null +++ b/packages/sdk/src/models/track-tokens-op.ts @@ -0,0 +1,578 @@ +/* + * 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 { OpenEnum } from "../types/enums.js"; +import { Result as SafeParseResult } from "../types/fp.js"; +import * as types from "../types/primitives.js"; +import { smartUnion } from "../types/smart-union.js"; +import { Balance, Balance$inboundSchema } from "./balance.js"; +import { SDKValidationError } from "./sdk-validation-error.js"; + +export type TrackTokensGlobals = { + xApiVersion?: string | undefined; +}; + +export type TrackTokensParams = { + /** + * The ID of the customer. + */ + customerId: string; + /** + * The ID of the entity for entity-scoped balances. + */ + entityId?: string | undefined; + /** + * The ID of the AI credit system feature. Auto-detected from the customer's entitlements if omitted — only required when a customer has multiple AI credit systems. + */ + featureId?: string | undefined; + /** + * The AI model as '/' (e.g. 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). The provider is the first path segment and must match a provider + model key in models.dev. + */ + modelId: string; + /** + * Number of non-cached text input tokens consumed. Exclusive of cache and audio token pools. + */ + inputTokens: number; + /** + * Number of text output tokens consumed. Exclusive of the reasoning and audio output pools. + */ + outputTokens: number; + /** + * Number of cached input tokens read. + */ + cacheReadTokens?: number | undefined; + /** + * Number of input tokens written to the cache. + */ + cacheWriteTokens?: number | undefined; + /** + * Number of audio input tokens consumed. + */ + audioInputTokens?: number | undefined; + /** + * Number of audio output tokens generated. + */ + audioOutputTokens?: number | undefined; + /** + * Number of reasoning tokens generated. + */ + reasoningTokens?: number | undefined; + /** + * Additional properties to attach to this usage event. + */ + properties?: { [k: string]: any } | undefined; +}; + +export const TrackTokensIntervalEnum2 = { + OneOff: "one_off", + Minute: "minute", + Hour: "hour", + Day: "day", + Week: "week", + Month: "month", + Quarter: "quarter", + SemiAnnual: "semi_annual", + Year: "year", +} as const; +export type TrackTokensIntervalEnum2 = OpenEnum< + typeof TrackTokensIntervalEnum2 +>; + +/** + * The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals. + */ +export type TrackTokensIntervalUnion2 = TrackTokensIntervalEnum2 | string; + +export type TrackTokensReset2 = { + /** + * The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals. + */ + interval: TrackTokensIntervalEnum2 | string; + /** + * Number of intervals between resets (eg. 2 for bi-monthly). + */ + intervalCount?: number | undefined; + /** + * Timestamp when the balance will next reset. + */ + resetsAt: number | null; +}; + +export type TrackTokensDeduction2 = { + /** + * ID of the underlying balance row that was deducted from (customer_entitlement or rollover). + */ + balanceId: string; + /** + * The feature this balance belongs to. + */ + featureId: string; + /** + * ID of the plan/product this balance belongs to. Null when the balance can't be attributed to a single plan (e.g. it spans multiple). + */ + planId: string | null; + /** + * Reset configuration for the balance this deduction came from, or null if the balance doesn't reset. + */ + reset: TrackTokensReset2 | null; + /** + * Amount deducted from this balance. Positive when usage was consumed, negative when credit was restored (e.g. a refund via negative track value). + */ + value: number; +}; + +/** + * Accepted. Autumn is experiencing degraded service from a downstream provider, so the token usage event was accepted for replay and will be tracked as soon as the service is restored. + */ +export type TrackTokensResponseBody2 = { + /** + * The ID of the customer whose usage was tracked. + */ + customerId: string; + /** + * The ID of the entity, if entity-scoped tracking was performed. + */ + entityId?: string | undefined; + /** + * The event name that was tracked, if event_name was used instead of feature_id. + */ + eventName?: string | undefined; + /** + * The amount of usage that was recorded. + */ + value: number; + /** + * The updated balance for the tracked feature. Null if tracking by event_name that affects multiple features. + */ + balance: Balance | null; + /** + * Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature. + */ + balances?: { [k: string]: Balance | null } | undefined; + /** + * Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling. + */ + deductions?: Array | undefined; +}; + +export const TrackTokensIntervalEnum1 = { + OneOff: "one_off", + Minute: "minute", + Hour: "hour", + Day: "day", + Week: "week", + Month: "month", + Quarter: "quarter", + SemiAnnual: "semi_annual", + Year: "year", +} as const; +export type TrackTokensIntervalEnum1 = OpenEnum< + typeof TrackTokensIntervalEnum1 +>; + +/** + * The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals. + */ +export type TrackTokensIntervalUnion1 = TrackTokensIntervalEnum1 | string; + +export type TrackTokensReset1 = { + /** + * The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals. + */ + interval: TrackTokensIntervalEnum1 | string; + /** + * Number of intervals between resets (eg. 2 for bi-monthly). + */ + intervalCount?: number | undefined; + /** + * Timestamp when the balance will next reset. + */ + resetsAt: number | null; +}; + +export type TrackTokensDeduction1 = { + /** + * ID of the underlying balance row that was deducted from (customer_entitlement or rollover). + */ + balanceId: string; + /** + * The feature this balance belongs to. + */ + featureId: string; + /** + * ID of the plan/product this balance belongs to. Null when the balance can't be attributed to a single plan (e.g. it spans multiple). + */ + planId: string | null; + /** + * Reset configuration for the balance this deduction came from, or null if the balance doesn't reset. + */ + reset: TrackTokensReset1 | null; + /** + * Amount deducted from this balance. Positive when usage was consumed, negative when credit was restored (e.g. a refund via negative track value). + */ + value: number; +}; + +/** + * OK + */ +export type TrackTokensResponseBody1 = { + /** + * The ID of the customer whose usage was tracked. + */ + customerId: string; + /** + * The ID of the entity, if entity-scoped tracking was performed. + */ + entityId?: string | undefined; + /** + * The event name that was tracked, if event_name was used instead of feature_id. + */ + eventName?: string | undefined; + /** + * The amount of usage that was recorded. + */ + value: number; + /** + * The updated balance for the tracked feature. Null if tracking by event_name that affects multiple features. + */ + balance: Balance | null; + /** + * Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature. + */ + balances?: { [k: string]: Balance | null } | undefined; + /** + * Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling. + */ + deductions?: Array | undefined; +}; + +export type TrackTokensResponse = + | TrackTokensResponseBody1 + | TrackTokensResponseBody2; + +/** @internal */ +export type TrackTokensParams$Outbound = { + customer_id: string; + entity_id?: string | undefined; + feature_id?: string | undefined; + model_id: string; + input_tokens: number; + output_tokens: number; + cache_read_tokens?: number | undefined; + cache_write_tokens?: number | undefined; + audio_input_tokens?: number | undefined; + audio_output_tokens?: number | undefined; + reasoning_tokens?: number | undefined; + properties?: { [k: string]: any } | undefined; +}; + +/** @internal */ +export const TrackTokensParams$outboundSchema: z.ZodMiniType< + TrackTokensParams$Outbound, + TrackTokensParams +> = z.pipe( + z.object({ + customerId: z.string(), + entityId: z.optional(z.string()), + featureId: z.optional(z.string()), + modelId: z.string(), + inputTokens: z.int(), + outputTokens: z.int(), + cacheReadTokens: z.optional(z.int()), + cacheWriteTokens: z.optional(z.int()), + audioInputTokens: z.optional(z.int()), + audioOutputTokens: z.optional(z.int()), + reasoningTokens: z.optional(z.int()), + properties: z.optional(z.record(z.string(), z.any())), + }), + z.transform((v) => { + return remap$(v, { + customerId: "customer_id", + entityId: "entity_id", + featureId: "feature_id", + modelId: "model_id", + inputTokens: "input_tokens", + outputTokens: "output_tokens", + cacheReadTokens: "cache_read_tokens", + cacheWriteTokens: "cache_write_tokens", + audioInputTokens: "audio_input_tokens", + audioOutputTokens: "audio_output_tokens", + reasoningTokens: "reasoning_tokens", + }); + }), +); + +export function trackTokensParamsToJSON( + trackTokensParams: TrackTokensParams, +): string { + return JSON.stringify( + TrackTokensParams$outboundSchema.parse(trackTokensParams), + ); +} + +/** @internal */ +export const TrackTokensIntervalEnum2$inboundSchema: z.ZodMiniType< + TrackTokensIntervalEnum2, + unknown +> = openEnums.inboundSchema(TrackTokensIntervalEnum2); + +/** @internal */ +export const TrackTokensIntervalUnion2$inboundSchema: z.ZodMiniType< + TrackTokensIntervalUnion2, + unknown +> = smartUnion([TrackTokensIntervalEnum2$inboundSchema, types.string()]); + +export function trackTokensIntervalUnion2FromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => TrackTokensIntervalUnion2$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'TrackTokensIntervalUnion2' from JSON`, + ); +} + +/** @internal */ +export const TrackTokensReset2$inboundSchema: z.ZodMiniType< + TrackTokensReset2, + unknown +> = z.pipe( + z.object({ + interval: smartUnion([ + TrackTokensIntervalEnum2$inboundSchema, + types.string(), + ]), + interval_count: types.optional(types.number()), + resets_at: types.nullable(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "interval_count": "intervalCount", + "resets_at": "resetsAt", + }); + }), +); + +export function trackTokensReset2FromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => TrackTokensReset2$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'TrackTokensReset2' from JSON`, + ); +} + +/** @internal */ +export const TrackTokensDeduction2$inboundSchema: z.ZodMiniType< + TrackTokensDeduction2, + unknown +> = z.pipe( + z.object({ + balance_id: types.string(), + feature_id: types.string(), + plan_id: types.nullable(types.string()), + reset: types.nullable(z.lazy(() => TrackTokensReset2$inboundSchema)), + value: types.number(), + }), + z.transform((v) => { + return remap$(v, { + "balance_id": "balanceId", + "feature_id": "featureId", + "plan_id": "planId", + }); + }), +); + +export function trackTokensDeduction2FromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => TrackTokensDeduction2$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'TrackTokensDeduction2' from JSON`, + ); +} + +/** @internal */ +export const TrackTokensResponseBody2$inboundSchema: z.ZodMiniType< + TrackTokensResponseBody2, + unknown +> = z.pipe( + z.object({ + customer_id: types.string(), + entity_id: types.optional(types.string()), + event_name: types.optional(types.string()), + value: types.number(), + balance: types.nullable(Balance$inboundSchema), + balances: types.optional( + z.record(z.string(), types.nullable(Balance$inboundSchema)), + ), + deductions: types.optional( + z.array(z.lazy(() => TrackTokensDeduction2$inboundSchema)), + ), + }), + z.transform((v) => { + return remap$(v, { + "customer_id": "customerId", + "entity_id": "entityId", + "event_name": "eventName", + }); + }), +); + +export function trackTokensResponseBody2FromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => TrackTokensResponseBody2$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'TrackTokensResponseBody2' from JSON`, + ); +} + +/** @internal */ +export const TrackTokensIntervalEnum1$inboundSchema: z.ZodMiniType< + TrackTokensIntervalEnum1, + unknown +> = openEnums.inboundSchema(TrackTokensIntervalEnum1); + +/** @internal */ +export const TrackTokensIntervalUnion1$inboundSchema: z.ZodMiniType< + TrackTokensIntervalUnion1, + unknown +> = smartUnion([TrackTokensIntervalEnum1$inboundSchema, types.string()]); + +export function trackTokensIntervalUnion1FromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => TrackTokensIntervalUnion1$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'TrackTokensIntervalUnion1' from JSON`, + ); +} + +/** @internal */ +export const TrackTokensReset1$inboundSchema: z.ZodMiniType< + TrackTokensReset1, + unknown +> = z.pipe( + z.object({ + interval: smartUnion([ + TrackTokensIntervalEnum1$inboundSchema, + types.string(), + ]), + interval_count: types.optional(types.number()), + resets_at: types.nullable(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "interval_count": "intervalCount", + "resets_at": "resetsAt", + }); + }), +); + +export function trackTokensReset1FromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => TrackTokensReset1$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'TrackTokensReset1' from JSON`, + ); +} + +/** @internal */ +export const TrackTokensDeduction1$inboundSchema: z.ZodMiniType< + TrackTokensDeduction1, + unknown +> = z.pipe( + z.object({ + balance_id: types.string(), + feature_id: types.string(), + plan_id: types.nullable(types.string()), + reset: types.nullable(z.lazy(() => TrackTokensReset1$inboundSchema)), + value: types.number(), + }), + z.transform((v) => { + return remap$(v, { + "balance_id": "balanceId", + "feature_id": "featureId", + "plan_id": "planId", + }); + }), +); + +export function trackTokensDeduction1FromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => TrackTokensDeduction1$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'TrackTokensDeduction1' from JSON`, + ); +} + +/** @internal */ +export const TrackTokensResponseBody1$inboundSchema: z.ZodMiniType< + TrackTokensResponseBody1, + unknown +> = z.pipe( + z.object({ + customer_id: types.string(), + entity_id: types.optional(types.string()), + event_name: types.optional(types.string()), + value: types.number(), + balance: types.nullable(Balance$inboundSchema), + balances: types.optional( + z.record(z.string(), types.nullable(Balance$inboundSchema)), + ), + deductions: types.optional( + z.array(z.lazy(() => TrackTokensDeduction1$inboundSchema)), + ), + }), + z.transform((v) => { + return remap$(v, { + "customer_id": "customerId", + "entity_id": "entityId", + "event_name": "eventName", + }); + }), +); + +export function trackTokensResponseBody1FromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => TrackTokensResponseBody1$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'TrackTokensResponseBody1' from JSON`, + ); +} + +/** @internal */ +export const TrackTokensResponse$inboundSchema: z.ZodMiniType< + TrackTokensResponse, + unknown +> = smartUnion([ + z.lazy(() => TrackTokensResponseBody1$inboundSchema), + z.lazy(() => TrackTokensResponseBody2$inboundSchema), +]); + +export function trackTokensResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => TrackTokensResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'TrackTokensResponse' from JSON`, + ); +} diff --git a/packages/sdk/src/models/update-customer-op.ts b/packages/sdk/src/models/update-customer-op.ts index 7357d5869..c5d5581fb 100644 --- a/packages/sdk/src/models/update-customer-op.ts +++ b/packages/sdk/src/models/update-customer-op.ts @@ -21,7 +21,7 @@ export type UpdateCustomerGlobals = { /** * The time interval for the purchase limit window. */ -export const UpdateCustomerIntervalRequest = { +export const UpdateCustomerIntervalRequestBody = { Hour: "hour", Day: "day", Week: "week", @@ -30,8 +30,8 @@ export const UpdateCustomerIntervalRequest = { /** * The time interval for the purchase limit window. */ -export type UpdateCustomerIntervalRequest = ClosedEnum< - typeof UpdateCustomerIntervalRequest +export type UpdateCustomerIntervalRequestBody = ClosedEnum< + typeof UpdateCustomerIntervalRequestBody >; /** @@ -41,7 +41,7 @@ export type UpdateCustomerPurchaseLimitRequest = { /** * The time interval for the purchase limit window. */ - interval: UpdateCustomerIntervalRequest; + interval: UpdateCustomerIntervalRequestBody; /** * Number of intervals in the purchase limit window. */ @@ -544,15 +544,16 @@ export type UpdateCustomerPurchase = { }; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export const UpdateCustomerType = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export type UpdateCustomerType = OpenEnum; @@ -567,6 +568,16 @@ export type UpdateCustomerCreditSchema = { creditCost: number; }; +export type UpdateCustomerModelMarkups = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type UpdateCustomerProviderMarkups = { + markup: number; +}; + /** * Display names for the feature in billing UI and customer-facing components. */ @@ -594,7 +605,7 @@ export type UpdateCustomerFeature = { */ name: string; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ type: UpdateCustomerType; /** @@ -609,6 +620,21 @@ export type UpdateCustomerFeature = { * For credit_system features: maps metered features to their credit costs. */ creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. + */ + modelMarkups?: { [k: string]: UpdateCustomerModelMarkups } | null | undefined; + /** + * Default percentage markup for AI credit systems. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. + */ + providerMarkups?: + | { [k: string]: UpdateCustomerProviderMarkups } + | null + | undefined; /** * Display names for the feature in billing UI and customer-facing components. */ @@ -775,9 +801,9 @@ export type UpdateCustomerResponse = { }; /** @internal */ -export const UpdateCustomerIntervalRequest$outboundSchema: z.ZodMiniEnum< - typeof UpdateCustomerIntervalRequest -> = z.enum(UpdateCustomerIntervalRequest); +export const UpdateCustomerIntervalRequestBody$outboundSchema: z.ZodMiniEnum< + typeof UpdateCustomerIntervalRequestBody +> = z.enum(UpdateCustomerIntervalRequestBody); /** @internal */ export type UpdateCustomerPurchaseLimitRequest$Outbound = { @@ -792,7 +818,7 @@ export const UpdateCustomerPurchaseLimitRequest$outboundSchema: z.ZodMiniType< UpdateCustomerPurchaseLimitRequest > = z.pipe( z.object({ - interval: UpdateCustomerIntervalRequest$outboundSchema, + interval: UpdateCustomerIntervalRequestBody$outboundSchema, intervalCount: z._default(z.number(), 1), limit: z.number(), }), @@ -1497,6 +1523,52 @@ export function updateCustomerCreditSchemaFromJSON( ); } +/** @internal */ +export const UpdateCustomerModelMarkups$inboundSchema: z.ZodMiniType< + UpdateCustomerModelMarkups, + unknown +> = z.pipe( + z.object({ + markup: types.optional(types.number()), + input_cost: types.optional(types.number()), + output_cost: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "input_cost": "inputCost", + "output_cost": "outputCost", + }); + }), +); + +export function updateCustomerModelMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => UpdateCustomerModelMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'UpdateCustomerModelMarkups' from JSON`, + ); +} + +/** @internal */ +export const UpdateCustomerProviderMarkups$inboundSchema: z.ZodMiniType< + UpdateCustomerProviderMarkups, + unknown +> = z.object({ + markup: types.number(), +}); + +export function updateCustomerProviderMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => UpdateCustomerProviderMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'UpdateCustomerProviderMarkups' from JSON`, + ); +} + /** @internal */ export const UpdateCustomerDisplay$inboundSchema: z.ZodMiniType< UpdateCustomerDisplay, @@ -1530,13 +1602,27 @@ export const UpdateCustomerFeature$inboundSchema: z.ZodMiniType< credit_schema: types.optional( z.array(z.lazy(() => UpdateCustomerCreditSchema$inboundSchema)), ), - display: types.optional(z.lazy(() => UpdateCustomerDisplay$inboundSchema)), + model_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => UpdateCustomerModelMarkups$inboundSchema), + ))), + default_markup: types.optional(types.number()), + provider_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => UpdateCustomerProviderMarkups$inboundSchema), + ))), + display: types.optional(z.lazy(() => + UpdateCustomerDisplay$inboundSchema + )), archived: types.boolean(), }), z.transform((v) => { return remap$(v, { "event_names": "eventNames", "credit_schema": "creditSchema", + "model_markups": "modelMarkups", + "default_markup": "defaultMarkup", + "provider_markups": "providerMarkups", }); }), ); diff --git a/packages/sdk/src/models/update-entity-op.ts b/packages/sdk/src/models/update-entity-op.ts index 29b3ae109..588298dd0 100644 --- a/packages/sdk/src/models/update-entity-op.ts +++ b/packages/sdk/src/models/update-entity-op.ts @@ -252,15 +252,16 @@ export type UpdateEntityPurchase = { }; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export const UpdateEntityType = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export type UpdateEntityType = OpenEnum; @@ -275,6 +276,16 @@ export type UpdateEntityCreditSchema = { creditCost: number; }; +export type UpdateEntityModelMarkups = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type UpdateEntityProviderMarkups = { + markup: number; +}; + /** * Display names for the feature in billing UI and customer-facing components. */ @@ -302,7 +313,7 @@ export type UpdateEntityFeature = { */ name: string; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ type: UpdateEntityType; /** @@ -317,6 +328,21 @@ export type UpdateEntityFeature = { * For credit_system features: maps metered features to their credit costs. */ creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. + */ + modelMarkups?: { [k: string]: UpdateEntityModelMarkups } | null | undefined; + /** + * Default percentage markup for AI credit systems. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. + */ + providerMarkups?: + | { [k: string]: UpdateEntityProviderMarkups } + | null + | undefined; /** * Display names for the feature in billing UI and customer-facing components. */ @@ -852,6 +878,52 @@ export function updateEntityCreditSchemaFromJSON( ); } +/** @internal */ +export const UpdateEntityModelMarkups$inboundSchema: z.ZodMiniType< + UpdateEntityModelMarkups, + unknown +> = z.pipe( + z.object({ + markup: types.optional(types.number()), + input_cost: types.optional(types.number()), + output_cost: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "input_cost": "inputCost", + "output_cost": "outputCost", + }); + }), +); + +export function updateEntityModelMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => UpdateEntityModelMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'UpdateEntityModelMarkups' from JSON`, + ); +} + +/** @internal */ +export const UpdateEntityProviderMarkups$inboundSchema: z.ZodMiniType< + UpdateEntityProviderMarkups, + unknown +> = z.object({ + markup: types.number(), +}); + +export function updateEntityProviderMarkupsFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => UpdateEntityProviderMarkups$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'UpdateEntityProviderMarkups' from JSON`, + ); +} + /** @internal */ export const UpdateEntityDisplay$inboundSchema: z.ZodMiniType< UpdateEntityDisplay, @@ -885,13 +957,27 @@ export const UpdateEntityFeature$inboundSchema: z.ZodMiniType< credit_schema: types.optional( z.array(z.lazy(() => UpdateEntityCreditSchema$inboundSchema)), ), - display: types.optional(z.lazy(() => UpdateEntityDisplay$inboundSchema)), + model_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => UpdateEntityModelMarkups$inboundSchema), + ))), + default_markup: types.optional(types.number()), + provider_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => UpdateEntityProviderMarkups$inboundSchema), + ))), + display: types.optional(z.lazy(() => + UpdateEntityDisplay$inboundSchema + )), archived: types.boolean(), }), z.transform((v) => { return remap$(v, { "event_names": "eventNames", "credit_schema": "creditSchema", + "model_markups": "modelMarkups", + "default_markup": "defaultMarkup", + "provider_markups": "providerMarkups", }); }), ); diff --git a/packages/sdk/src/models/update-feature-op.ts b/packages/sdk/src/models/update-feature-op.ts index b1b8e8044..1564c4312 100644 --- a/packages/sdk/src/models/update-feature-op.ts +++ b/packages/sdk/src/models/update-feature-op.ts @@ -18,31 +18,42 @@ export type UpdateFeatureGlobals = { /** * The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. */ -export const UpdateFeatureTypeRequest = { +export const UpdateFeatureTypeRequestBody = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** * The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. */ -export type UpdateFeatureTypeRequest = ClosedEnum< - typeof UpdateFeatureTypeRequest +export type UpdateFeatureTypeRequestBody = ClosedEnum< + typeof UpdateFeatureTypeRequestBody >; /** * Singular and plural display names for the feature in your user interface. */ -export type UpdateFeatureDisplayRequest = { +export type UpdateFeatureDisplayRequestBody = { singular: string; plural: string; }; -export type UpdateFeatureCreditSchemaRequest = { +export type UpdateFeatureCreditSchemaRequestBody = { meteredFeatureId: string; creditCost: number; }; +export type UpdateFeatureModelMarkupsRequest = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type UpdateFeatureProviderMarkupsRequest = { + markup: number; +}; + export type UpdateFeatureParams = { /** * The name of the feature. @@ -51,7 +62,7 @@ export type UpdateFeatureParams = { /** * The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. */ - type?: UpdateFeatureTypeRequest | undefined; + type?: UpdateFeatureTypeRequestBody | undefined; /** * Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features. */ @@ -59,11 +70,29 @@ export type UpdateFeatureParams = { /** * Singular and plural display names for the feature in your user interface. */ - display?: UpdateFeatureDisplayRequest | undefined; + display?: UpdateFeatureDisplayRequestBody | undefined; /** - * A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. + * A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. */ - creditSchema?: Array | undefined; + creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. + */ + modelMarkups?: + | { [k: string]: UpdateFeatureModelMarkupsRequest } + | null + | undefined; + /** + * Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. + */ + providerMarkups?: + | { [k: string]: UpdateFeatureProviderMarkupsRequest } + | null + | undefined; eventNames?: Array | undefined; /** * Whether the feature is archived. Archived features are hidden from the dashboard. @@ -80,15 +109,16 @@ export type UpdateFeatureParams = { }; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export const UpdateFeatureTypeResponse = { Boolean: "boolean", Metered: "metered", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ export type UpdateFeatureTypeResponse = OpenEnum< typeof UpdateFeatureTypeResponse @@ -105,6 +135,16 @@ export type UpdateFeatureCreditSchemaResponse = { creditCost: number; }; +export type UpdateFeatureModelMarkupsResponse = { + markup?: number | undefined; + inputCost?: number | undefined; + outputCost?: number | undefined; +}; + +export type UpdateFeatureProviderMarkupsResponse = { + markup: number; +}; + /** * Display names for the feature in billing UI and customer-facing components. */ @@ -132,7 +172,7 @@ export type UpdateFeatureResponse = { */ name: string; /** - * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools. + * Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing. */ type: UpdateFeatureTypeResponse; /** @@ -147,6 +187,24 @@ export type UpdateFeatureResponse = { * For credit_system features: maps metered features to their credit costs. */ creditSchema?: Array | undefined; + /** + * Per-model markup overrides for AI credit systems. + */ + modelMarkups?: + | { [k: string]: UpdateFeatureModelMarkupsResponse } + | null + | undefined; + /** + * Default percentage markup for AI credit systems. Use -100 to make usage free. + */ + defaultMarkup?: number | undefined; + /** + * Per-provider default markup percentages for AI credit systems. + */ + providerMarkups?: + | { [k: string]: UpdateFeatureProviderMarkupsResponse } + | null + | undefined; /** * Display names for the feature in billing UI and customer-facing components. */ @@ -158,45 +216,45 @@ export type UpdateFeatureResponse = { }; /** @internal */ -export const UpdateFeatureTypeRequest$outboundSchema: z.ZodMiniEnum< - typeof UpdateFeatureTypeRequest -> = z.enum(UpdateFeatureTypeRequest); +export const UpdateFeatureTypeRequestBody$outboundSchema: z.ZodMiniEnum< + typeof UpdateFeatureTypeRequestBody +> = z.enum(UpdateFeatureTypeRequestBody); /** @internal */ -export type UpdateFeatureDisplayRequest$Outbound = { +export type UpdateFeatureDisplayRequestBody$Outbound = { singular: string; plural: string; }; /** @internal */ -export const UpdateFeatureDisplayRequest$outboundSchema: z.ZodMiniType< - UpdateFeatureDisplayRequest$Outbound, - UpdateFeatureDisplayRequest +export const UpdateFeatureDisplayRequestBody$outboundSchema: z.ZodMiniType< + UpdateFeatureDisplayRequestBody$Outbound, + UpdateFeatureDisplayRequestBody > = z.object({ singular: z.string(), plural: z.string(), }); -export function updateFeatureDisplayRequestToJSON( - updateFeatureDisplayRequest: UpdateFeatureDisplayRequest, +export function updateFeatureDisplayRequestBodyToJSON( + updateFeatureDisplayRequestBody: UpdateFeatureDisplayRequestBody, ): string { return JSON.stringify( - UpdateFeatureDisplayRequest$outboundSchema.parse( - updateFeatureDisplayRequest, + UpdateFeatureDisplayRequestBody$outboundSchema.parse( + updateFeatureDisplayRequestBody, ), ); } /** @internal */ -export type UpdateFeatureCreditSchemaRequest$Outbound = { +export type UpdateFeatureCreditSchemaRequestBody$Outbound = { metered_feature_id: string; credit_cost: number; }; /** @internal */ -export const UpdateFeatureCreditSchemaRequest$outboundSchema: z.ZodMiniType< - UpdateFeatureCreditSchemaRequest$Outbound, - UpdateFeatureCreditSchemaRequest +export const UpdateFeatureCreditSchemaRequestBody$outboundSchema: z.ZodMiniType< + UpdateFeatureCreditSchemaRequestBody$Outbound, + UpdateFeatureCreditSchemaRequestBody > = z.pipe( z.object({ meteredFeatureId: z.string(), @@ -210,12 +268,70 @@ export const UpdateFeatureCreditSchemaRequest$outboundSchema: z.ZodMiniType< }), ); -export function updateFeatureCreditSchemaRequestToJSON( - updateFeatureCreditSchemaRequest: UpdateFeatureCreditSchemaRequest, +export function updateFeatureCreditSchemaRequestBodyToJSON( + updateFeatureCreditSchemaRequestBody: UpdateFeatureCreditSchemaRequestBody, ): string { return JSON.stringify( - UpdateFeatureCreditSchemaRequest$outboundSchema.parse( - updateFeatureCreditSchemaRequest, + UpdateFeatureCreditSchemaRequestBody$outboundSchema.parse( + updateFeatureCreditSchemaRequestBody, + ), + ); +} + +/** @internal */ +export type UpdateFeatureModelMarkupsRequest$Outbound = { + markup?: number | undefined; + input_cost?: number | undefined; + output_cost?: number | undefined; +}; + +/** @internal */ +export const UpdateFeatureModelMarkupsRequest$outboundSchema: z.ZodMiniType< + UpdateFeatureModelMarkupsRequest$Outbound, + UpdateFeatureModelMarkupsRequest +> = z.pipe( + z.object({ + markup: z.optional(z.number()), + inputCost: z.optional(z.number()), + outputCost: z.optional(z.number()), + }), + z.transform((v) => { + return remap$(v, { + inputCost: "input_cost", + outputCost: "output_cost", + }); + }), +); + +export function updateFeatureModelMarkupsRequestToJSON( + updateFeatureModelMarkupsRequest: UpdateFeatureModelMarkupsRequest, +): string { + return JSON.stringify( + UpdateFeatureModelMarkupsRequest$outboundSchema.parse( + updateFeatureModelMarkupsRequest, + ), + ); +} + +/** @internal */ +export type UpdateFeatureProviderMarkupsRequest$Outbound = { + markup: number; +}; + +/** @internal */ +export const UpdateFeatureProviderMarkupsRequest$outboundSchema: z.ZodMiniType< + UpdateFeatureProviderMarkupsRequest$Outbound, + UpdateFeatureProviderMarkupsRequest +> = z.object({ + markup: z.number(), +}); + +export function updateFeatureProviderMarkupsRequestToJSON( + updateFeatureProviderMarkupsRequest: UpdateFeatureProviderMarkupsRequest, +): string { + return JSON.stringify( + UpdateFeatureProviderMarkupsRequest$outboundSchema.parse( + updateFeatureProviderMarkupsRequest, ), ); } @@ -225,8 +341,19 @@ export type UpdateFeatureParams$Outbound = { name?: string | undefined; type?: string | undefined; consumable?: boolean | undefined; - display?: UpdateFeatureDisplayRequest$Outbound | undefined; - credit_schema?: Array | undefined; + display?: UpdateFeatureDisplayRequestBody$Outbound | undefined; + credit_schema?: + | Array + | undefined; + model_markups?: + | { [k: string]: UpdateFeatureModelMarkupsRequest$Outbound } + | null + | undefined; + default_markup?: number | undefined; + provider_markups?: + | { [k: string]: UpdateFeatureProviderMarkupsRequest$Outbound } + | null + | undefined; event_names?: Array | undefined; archived?: boolean | undefined; feature_id: string; @@ -240,13 +367,28 @@ export const UpdateFeatureParams$outboundSchema: z.ZodMiniType< > = z.pipe( z.object({ name: z.optional(z.string()), - type: z.optional(UpdateFeatureTypeRequest$outboundSchema), + type: z.optional(UpdateFeatureTypeRequestBody$outboundSchema), consumable: z.optional(z.boolean()), display: z.optional( - z.lazy(() => UpdateFeatureDisplayRequest$outboundSchema), + z.lazy(() => UpdateFeatureDisplayRequestBody$outboundSchema), ), creditSchema: z.optional( - z.array(z.lazy(() => UpdateFeatureCreditSchemaRequest$outboundSchema)), + z.array( + z.lazy(() => UpdateFeatureCreditSchemaRequestBody$outboundSchema), + ), + ), + modelMarkups: z.optional( + z.nullable(z.record( + z.string(), + z.lazy(() => UpdateFeatureModelMarkupsRequest$outboundSchema), + )), + ), + defaultMarkup: z.optional(z.number()), + providerMarkups: z.optional( + z.nullable(z.record( + z.string(), + z.lazy(() => UpdateFeatureProviderMarkupsRequest$outboundSchema), + )), ), eventNames: z.optional(z.array(z.string())), archived: z.optional(z.boolean()), @@ -256,6 +398,9 @@ export const UpdateFeatureParams$outboundSchema: z.ZodMiniType< z.transform((v) => { return remap$(v, { creditSchema: "credit_schema", + modelMarkups: "model_markups", + defaultMarkup: "default_markup", + providerMarkups: "provider_markups", eventNames: "event_names", featureId: "feature_id", newFeatureId: "new_feature_id", @@ -304,6 +449,53 @@ export function updateFeatureCreditSchemaResponseFromJSON( ); } +/** @internal */ +export const UpdateFeatureModelMarkupsResponse$inboundSchema: z.ZodMiniType< + UpdateFeatureModelMarkupsResponse, + unknown +> = z.pipe( + z.object({ + markup: types.optional(types.number()), + input_cost: types.optional(types.number()), + output_cost: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "input_cost": "inputCost", + "output_cost": "outputCost", + }); + }), +); + +export function updateFeatureModelMarkupsResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => UpdateFeatureModelMarkupsResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'UpdateFeatureModelMarkupsResponse' from JSON`, + ); +} + +/** @internal */ +export const UpdateFeatureProviderMarkupsResponse$inboundSchema: z.ZodMiniType< + UpdateFeatureProviderMarkupsResponse, + unknown +> = z.object({ + markup: types.number(), +}); + +export function updateFeatureProviderMarkupsResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => + UpdateFeatureProviderMarkupsResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'UpdateFeatureProviderMarkupsResponse' from JSON`, + ); +} + /** @internal */ export const UpdateFeatureDisplayResponse$inboundSchema: z.ZodMiniType< UpdateFeatureDisplayResponse, @@ -337,15 +529,27 @@ export const UpdateFeatureResponse$inboundSchema: z.ZodMiniType< credit_schema: types.optional( z.array(z.lazy(() => UpdateFeatureCreditSchemaResponse$inboundSchema)), ), - display: types.optional( - z.lazy(() => UpdateFeatureDisplayResponse$inboundSchema), - ), + model_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => UpdateFeatureModelMarkupsResponse$inboundSchema), + ))), + default_markup: types.optional(types.number()), + provider_markups: z.optional(z.nullable(z.record( + z.string(), + z.lazy(() => UpdateFeatureProviderMarkupsResponse$inboundSchema), + ))), + display: types.optional(z.lazy(() => + UpdateFeatureDisplayResponse$inboundSchema + )), archived: types.boolean(), }), z.transform((v) => { return remap$(v, { "event_names": "eventNames", "credit_schema": "creditSchema", + "model_markups": "modelMarkups", + "default_markup": "defaultMarkup", + "provider_markups": "providerMarkups", }); }), ); diff --git a/packages/sdk/src/models/update-plan-op.ts b/packages/sdk/src/models/update-plan-op.ts index e9b5f6df0..085303baf 100644 --- a/packages/sdk/src/models/update-plan-op.ts +++ b/packages/sdk/src/models/update-plan-op.ts @@ -19,7 +19,7 @@ export type UpdatePlanGlobals = { /** * Billing interval (e.g. 'month', 'year'). */ -export const UpdatePlanPriceIntervalRequest = { +export const UpdatePlanPriceIntervalRequestBody = { OneOff: "one_off", Week: "week", Month: "month", @@ -30,8 +30,8 @@ export const UpdatePlanPriceIntervalRequest = { /** * Billing interval (e.g. 'month', 'year'). */ -export type UpdatePlanPriceIntervalRequest = ClosedEnum< - typeof UpdatePlanPriceIntervalRequest +export type UpdatePlanPriceIntervalRequestBody = ClosedEnum< + typeof UpdatePlanPriceIntervalRequestBody >; /** @@ -45,7 +45,7 @@ export type UpdatePlanBasePrice = { /** * Billing interval (e.g. 'month', 'year'). */ - interval: UpdatePlanPriceIntervalRequest; + interval: UpdatePlanPriceIntervalRequestBody; /** * Number of intervals per billing cycle. Defaults to 1. */ @@ -55,7 +55,7 @@ export type UpdatePlanBasePrice = { /** * Interval at which balance resets (e.g. 'month', 'year'). For consumable features only. */ -export const UpdatePlanResetIntervalRequest = { +export const UpdatePlanResetIntervalRequestBody = { OneOff: "one_off", Minute: "minute", Hour: "hour", @@ -69,44 +69,44 @@ export const UpdatePlanResetIntervalRequest = { /** * Interval at which balance resets (e.g. 'month', 'year'). For consumable features only. */ -export type UpdatePlanResetIntervalRequest = ClosedEnum< - typeof UpdatePlanResetIntervalRequest +export type UpdatePlanResetIntervalRequestBody = ClosedEnum< + typeof UpdatePlanResetIntervalRequestBody >; /** * Reset configuration for consumable features. Omit for non-consumable features like seats. */ -export type UpdatePlanResetRequest = { +export type UpdatePlanResetRequestBody = { /** * Interval at which balance resets (e.g. 'month', 'year'). For consumable features only. */ - interval: UpdatePlanResetIntervalRequest; + interval: UpdatePlanResetIntervalRequestBody; /** * Number of intervals between resets. Defaults to 1. */ intervalCount?: number | undefined; }; -export type UpdatePlanTo = number | string; +export type UpdatePlanToRequestBody = number | string; -export type UpdatePlanTier = { +export type UpdatePlanTierRequestBody = { to: number | string; amount?: number | undefined; flatAmount?: number | undefined; }; -export const UpdatePlanTierBehaviorRequest = { +export const UpdatePlanTierBehaviorRequestBody = { Graduated: "graduated", Volume: "volume", } as const; -export type UpdatePlanTierBehaviorRequest = ClosedEnum< - typeof UpdatePlanTierBehaviorRequest +export type UpdatePlanTierBehaviorRequestBody = ClosedEnum< + typeof UpdatePlanTierBehaviorRequestBody >; /** * Billing interval. For consumable features, should match reset.interval. */ -export const UpdatePlanItemPriceIntervalRequest = { +export const UpdatePlanItemPriceIntervalRequestBody = { OneOff: "one_off", Week: "week", Month: "month", @@ -117,28 +117,28 @@ export const UpdatePlanItemPriceIntervalRequest = { /** * Billing interval. For consumable features, should match reset.interval. */ -export type UpdatePlanItemPriceIntervalRequest = ClosedEnum< - typeof UpdatePlanItemPriceIntervalRequest +export type UpdatePlanItemPriceIntervalRequestBody = ClosedEnum< + typeof UpdatePlanItemPriceIntervalRequestBody >; /** * 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. */ -export const UpdatePlanBillingMethodRequest = { +export const UpdatePlanBillingMethodRequestBody = { Prepaid: "prepaid", UsageBased: "usage_based", } as const; /** * 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. */ -export type UpdatePlanBillingMethodRequest = ClosedEnum< - typeof UpdatePlanBillingMethodRequest +export type UpdatePlanBillingMethodRequestBody = ClosedEnum< + typeof UpdatePlanBillingMethodRequestBody >; /** * Pricing for usage beyond included units. Omit for free features. */ -export type UpdatePlanPriceRequest = { +export type UpdatePlanPriceRequestBody = { /** * Price per billing_units after included usage. Either 'amount' or 'tiers' is required. */ @@ -146,12 +146,12 @@ export type UpdatePlanPriceRequest = { /** * Tiered pricing. Either 'amount' or 'tiers' is required. */ - tiers?: Array | undefined; - tierBehavior?: UpdatePlanTierBehaviorRequest | undefined; + tiers?: Array | undefined; + tierBehavior?: UpdatePlanTierBehaviorRequestBody | undefined; /** * Billing interval. For consumable features, should match reset.interval. */ - interval: UpdatePlanItemPriceIntervalRequest; + interval: UpdatePlanItemPriceIntervalRequestBody; /** * Number of intervals per billing cycle. Defaults to 1. */ @@ -163,11 +163,11 @@ export type UpdatePlanPriceRequest = { /** * 'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go. */ - billingMethod: UpdatePlanBillingMethodRequest; + billingMethod: UpdatePlanBillingMethodRequestBody; /** - * 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. */ - maxPurchase?: number | undefined; + maxPurchase?: number | null | undefined; }; /** @@ -216,21 +216,21 @@ export type UpdatePlanProration = { /** * When rolled over units expire. */ -export const UpdatePlanExpiryDurationTypeRequest = { +export const UpdatePlanExpiryDurationTypeRequestBody = { Month: "month", Forever: "forever", } as const; /** * When rolled over units expire. */ -export type UpdatePlanExpiryDurationTypeRequest = ClosedEnum< - typeof UpdatePlanExpiryDurationTypeRequest +export type UpdatePlanExpiryDurationTypeRequestBody = ClosedEnum< + typeof UpdatePlanExpiryDurationTypeRequestBody >; /** * Rollover config for unused units. If set, unused included units carry over. */ -export type UpdatePlanRolloverRequest = { +export type UpdatePlanRolloverRequestBody = { /** * Max rollover units. Omit for unlimited rollover. */ @@ -242,7 +242,7 @@ export type UpdatePlanRolloverRequest = { /** * When rolled over units expire. */ - expiryDurationType: UpdatePlanExpiryDurationTypeRequest; + expiryDurationType: UpdatePlanExpiryDurationTypeRequestBody; /** * Number of periods before expiry. */ @@ -268,11 +268,11 @@ export type UpdatePlanPlanItem = { /** * Reset configuration for consumable features. Omit for non-consumable features like seats. */ - reset?: UpdatePlanResetRequest | undefined; + reset?: UpdatePlanResetRequestBody | undefined; /** * Pricing for usage beyond included units. Omit for free features. */ - price?: UpdatePlanPriceRequest | undefined; + price?: UpdatePlanPriceRequestBody | undefined; /** * Proration settings for prepaid features. Controls mid-cycle quantity change billing. */ @@ -280,7 +280,7 @@ export type UpdatePlanPlanItem = { /** * Rollover config for unused units. If set, unused included units carry over. */ - rollover?: UpdatePlanRolloverRequest | undefined; + rollover?: UpdatePlanRolloverRequestBody | undefined; }; /** @@ -387,6 +387,7 @@ export type UpdatePlanParams = { * The new ID to use for the plan. Can only be updated if the plan has not been used by any customers. */ newPlanId?: string | undefined; + disableVersion?: boolean | undefined; }; /** @@ -449,6 +450,7 @@ export const UpdatePlanType = { SingleUse: "single_use", ContinuousUse: "continuous_use", CreditSystem: "credit_system", + AiCreditSystem: "ai_credit_system", } as const; /** * The type of the feature @@ -539,6 +541,14 @@ export type UpdatePlanResetResponse = { intervalCount?: number | undefined; }; +export type UpdatePlanToResponse = number | string; + +export type UpdatePlanTierResponse = { + to: number | string; + amount: number; + flatAmount?: number | undefined; +}; + export const UpdatePlanTierBehaviorResponse = { Graduated: "graduated", Volume: "volume", @@ -587,7 +597,7 @@ export type UpdatePlanItemPriceResponse = { /** * Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required. */ - tiers?: Array | undefined; + tiers?: Array | undefined; tierBehavior?: UpdatePlanTierBehaviorResponse | undefined; /** * Billing interval for this price. For consumable features, should match reset.interval. @@ -879,9 +889,9 @@ export type UpdatePlanResponse = { }; /** @internal */ -export const UpdatePlanPriceIntervalRequest$outboundSchema: z.ZodMiniEnum< - typeof UpdatePlanPriceIntervalRequest -> = z.enum(UpdatePlanPriceIntervalRequest); +export const UpdatePlanPriceIntervalRequestBody$outboundSchema: z.ZodMiniEnum< + typeof UpdatePlanPriceIntervalRequestBody +> = z.enum(UpdatePlanPriceIntervalRequestBody); /** @internal */ export type UpdatePlanBasePrice$Outbound = { @@ -897,7 +907,7 @@ export const UpdatePlanBasePrice$outboundSchema: z.ZodMiniType< > = z.pipe( z.object({ amount: z.number(), - interval: UpdatePlanPriceIntervalRequest$outboundSchema, + interval: UpdatePlanPriceIntervalRequestBody$outboundSchema, intervalCount: z.optional(z.number()), }), z.transform((v) => { @@ -916,23 +926,23 @@ export function updatePlanBasePriceToJSON( } /** @internal */ -export const UpdatePlanResetIntervalRequest$outboundSchema: z.ZodMiniEnum< - typeof UpdatePlanResetIntervalRequest -> = z.enum(UpdatePlanResetIntervalRequest); +export const UpdatePlanResetIntervalRequestBody$outboundSchema: z.ZodMiniEnum< + typeof UpdatePlanResetIntervalRequestBody +> = z.enum(UpdatePlanResetIntervalRequestBody); /** @internal */ -export type UpdatePlanResetRequest$Outbound = { +export type UpdatePlanResetRequestBody$Outbound = { interval: string; interval_count?: number | undefined; }; /** @internal */ -export const UpdatePlanResetRequest$outboundSchema: z.ZodMiniType< - UpdatePlanResetRequest$Outbound, - UpdatePlanResetRequest +export const UpdatePlanResetRequestBody$outboundSchema: z.ZodMiniType< + UpdatePlanResetRequestBody$Outbound, + UpdatePlanResetRequestBody > = z.pipe( z.object({ - interval: UpdatePlanResetIntervalRequest$outboundSchema, + interval: UpdatePlanResetIntervalRequestBody$outboundSchema, intervalCount: z.optional(z.number()), }), z.transform((v) => { @@ -942,38 +952,42 @@ export const UpdatePlanResetRequest$outboundSchema: z.ZodMiniType< }), ); -export function updatePlanResetRequestToJSON( - updatePlanResetRequest: UpdatePlanResetRequest, +export function updatePlanResetRequestBodyToJSON( + updatePlanResetRequestBody: UpdatePlanResetRequestBody, ): string { return JSON.stringify( - UpdatePlanResetRequest$outboundSchema.parse(updatePlanResetRequest), + UpdatePlanResetRequestBody$outboundSchema.parse(updatePlanResetRequestBody), ); } /** @internal */ -export type UpdatePlanTo$Outbound = number | string; +export type UpdatePlanToRequestBody$Outbound = number | string; /** @internal */ -export const UpdatePlanTo$outboundSchema: z.ZodMiniType< - UpdatePlanTo$Outbound, - UpdatePlanTo +export const UpdatePlanToRequestBody$outboundSchema: z.ZodMiniType< + UpdatePlanToRequestBody$Outbound, + UpdatePlanToRequestBody > = smartUnion([z.number(), z.string()]); -export function updatePlanToToJSON(updatePlanTo: UpdatePlanTo): string { - return JSON.stringify(UpdatePlanTo$outboundSchema.parse(updatePlanTo)); +export function updatePlanToRequestBodyToJSON( + updatePlanToRequestBody: UpdatePlanToRequestBody, +): string { + return JSON.stringify( + UpdatePlanToRequestBody$outboundSchema.parse(updatePlanToRequestBody), + ); } /** @internal */ -export type UpdatePlanTier$Outbound = { +export type UpdatePlanTierRequestBody$Outbound = { to: number | string; amount?: number | undefined; flat_amount?: number | undefined; }; /** @internal */ -export const UpdatePlanTier$outboundSchema: z.ZodMiniType< - UpdatePlanTier$Outbound, - UpdatePlanTier +export const UpdatePlanTierRequestBody$outboundSchema: z.ZodMiniType< + UpdatePlanTierRequestBody$Outbound, + UpdatePlanTierRequestBody > = z.pipe( z.object({ to: smartUnion([z.number(), z.string()]), @@ -987,51 +1001,58 @@ export const UpdatePlanTier$outboundSchema: z.ZodMiniType< }), ); -export function updatePlanTierToJSON(updatePlanTier: UpdatePlanTier): string { - return JSON.stringify(UpdatePlanTier$outboundSchema.parse(updatePlanTier)); +export function updatePlanTierRequestBodyToJSON( + updatePlanTierRequestBody: UpdatePlanTierRequestBody, +): string { + return JSON.stringify( + UpdatePlanTierRequestBody$outboundSchema.parse(updatePlanTierRequestBody), + ); } /** @internal */ -export const UpdatePlanTierBehaviorRequest$outboundSchema: z.ZodMiniEnum< - typeof UpdatePlanTierBehaviorRequest -> = z.enum(UpdatePlanTierBehaviorRequest); +export const UpdatePlanTierBehaviorRequestBody$outboundSchema: z.ZodMiniEnum< + typeof UpdatePlanTierBehaviorRequestBody +> = z.enum(UpdatePlanTierBehaviorRequestBody); /** @internal */ -export const UpdatePlanItemPriceIntervalRequest$outboundSchema: z.ZodMiniEnum< - typeof UpdatePlanItemPriceIntervalRequest -> = z.enum(UpdatePlanItemPriceIntervalRequest); +export const UpdatePlanItemPriceIntervalRequestBody$outboundSchema: + z.ZodMiniEnum = z.enum( + UpdatePlanItemPriceIntervalRequestBody, + ); /** @internal */ -export const UpdatePlanBillingMethodRequest$outboundSchema: z.ZodMiniEnum< - typeof UpdatePlanBillingMethodRequest -> = z.enum(UpdatePlanBillingMethodRequest); +export const UpdatePlanBillingMethodRequestBody$outboundSchema: z.ZodMiniEnum< + typeof UpdatePlanBillingMethodRequestBody +> = z.enum(UpdatePlanBillingMethodRequestBody); /** @internal */ -export type UpdatePlanPriceRequest$Outbound = { +export type UpdatePlanPriceRequestBody$Outbound = { amount?: number | undefined; - tiers?: Array | undefined; + tiers?: Array | undefined; tier_behavior?: string | undefined; interval: string; interval_count: number; billing_units: number; billing_method: string; - max_purchase?: number | undefined; + max_purchase?: number | null | undefined; }; /** @internal */ -export const UpdatePlanPriceRequest$outboundSchema: z.ZodMiniType< - UpdatePlanPriceRequest$Outbound, - UpdatePlanPriceRequest +export const UpdatePlanPriceRequestBody$outboundSchema: z.ZodMiniType< + UpdatePlanPriceRequestBody$Outbound, + UpdatePlanPriceRequestBody > = z.pipe( z.object({ amount: z.optional(z.number()), - tiers: z.optional(z.array(z.lazy(() => UpdatePlanTier$outboundSchema))), - tierBehavior: z.optional(UpdatePlanTierBehaviorRequest$outboundSchema), - interval: UpdatePlanItemPriceIntervalRequest$outboundSchema, + tiers: z.optional( + z.array(z.lazy(() => UpdatePlanTierRequestBody$outboundSchema)), + ), + tierBehavior: z.optional(UpdatePlanTierBehaviorRequestBody$outboundSchema), + interval: UpdatePlanItemPriceIntervalRequestBody$outboundSchema, intervalCount: z._default(z.number(), 1), billingUnits: z._default(z.number(), 1), - billingMethod: UpdatePlanBillingMethodRequest$outboundSchema, - maxPurchase: z.optional(z.number()), + billingMethod: UpdatePlanBillingMethodRequestBody$outboundSchema, + maxPurchase: z.optional(z.nullable(z.number())), }), z.transform((v) => { return remap$(v, { @@ -1044,11 +1065,11 @@ export const UpdatePlanPriceRequest$outboundSchema: z.ZodMiniType< }), ); -export function updatePlanPriceRequestToJSON( - updatePlanPriceRequest: UpdatePlanPriceRequest, +export function updatePlanPriceRequestBodyToJSON( + updatePlanPriceRequestBody: UpdatePlanPriceRequestBody, ): string { return JSON.stringify( - UpdatePlanPriceRequest$outboundSchema.parse(updatePlanPriceRequest), + UpdatePlanPriceRequestBody$outboundSchema.parse(updatePlanPriceRequestBody), ); } @@ -1094,12 +1115,13 @@ export function updatePlanProrationToJSON( } /** @internal */ -export const UpdatePlanExpiryDurationTypeRequest$outboundSchema: z.ZodMiniEnum< - typeof UpdatePlanExpiryDurationTypeRequest -> = z.enum(UpdatePlanExpiryDurationTypeRequest); +export const UpdatePlanExpiryDurationTypeRequestBody$outboundSchema: + z.ZodMiniEnum = z.enum( + UpdatePlanExpiryDurationTypeRequestBody, + ); /** @internal */ -export type UpdatePlanRolloverRequest$Outbound = { +export type UpdatePlanRolloverRequestBody$Outbound = { max?: number | undefined; max_percentage?: number | undefined; expiry_duration_type: string; @@ -1107,14 +1129,14 @@ export type UpdatePlanRolloverRequest$Outbound = { }; /** @internal */ -export const UpdatePlanRolloverRequest$outboundSchema: z.ZodMiniType< - UpdatePlanRolloverRequest$Outbound, - UpdatePlanRolloverRequest +export const UpdatePlanRolloverRequestBody$outboundSchema: z.ZodMiniType< + UpdatePlanRolloverRequestBody$Outbound, + UpdatePlanRolloverRequestBody > = z.pipe( z.object({ max: z.optional(z.number()), maxPercentage: z.optional(z.number()), - expiryDurationType: UpdatePlanExpiryDurationTypeRequest$outboundSchema, + expiryDurationType: UpdatePlanExpiryDurationTypeRequestBody$outboundSchema, expiryDurationLength: z.optional(z.number()), }), z.transform((v) => { @@ -1126,11 +1148,13 @@ export const UpdatePlanRolloverRequest$outboundSchema: z.ZodMiniType< }), ); -export function updatePlanRolloverRequestToJSON( - updatePlanRolloverRequest: UpdatePlanRolloverRequest, +export function updatePlanRolloverRequestBodyToJSON( + updatePlanRolloverRequestBody: UpdatePlanRolloverRequestBody, ): string { return JSON.stringify( - UpdatePlanRolloverRequest$outboundSchema.parse(updatePlanRolloverRequest), + UpdatePlanRolloverRequestBody$outboundSchema.parse( + updatePlanRolloverRequestBody, + ), ); } @@ -1139,10 +1163,10 @@ export type UpdatePlanPlanItem$Outbound = { feature_id: string; included?: number | undefined; unlimited?: boolean | undefined; - reset?: UpdatePlanResetRequest$Outbound | undefined; - price?: UpdatePlanPriceRequest$Outbound | undefined; + reset?: UpdatePlanResetRequestBody$Outbound | undefined; + price?: UpdatePlanPriceRequestBody$Outbound | undefined; proration?: UpdatePlanProration$Outbound | undefined; - rollover?: UpdatePlanRolloverRequest$Outbound | undefined; + rollover?: UpdatePlanRolloverRequestBody$Outbound | undefined; }; /** @internal */ @@ -1154,11 +1178,11 @@ export const UpdatePlanPlanItem$outboundSchema: z.ZodMiniType< featureId: z.string(), included: z.optional(z.number()), unlimited: z.optional(z.boolean()), - reset: z.optional(z.lazy(() => UpdatePlanResetRequest$outboundSchema)), - price: z.optional(z.lazy(() => UpdatePlanPriceRequest$outboundSchema)), + reset: z.optional(z.lazy(() => UpdatePlanResetRequestBody$outboundSchema)), + price: z.optional(z.lazy(() => UpdatePlanPriceRequestBody$outboundSchema)), proration: z.optional(z.lazy(() => UpdatePlanProration$outboundSchema)), rollover: z.optional( - z.lazy(() => UpdatePlanRolloverRequest$outboundSchema), + z.lazy(() => UpdatePlanRolloverRequestBody$outboundSchema), ), }), z.transform((v) => { @@ -1270,6 +1294,7 @@ export type UpdatePlanParams$Outbound = { version?: number | undefined; archived: boolean; new_plan_id?: string | undefined; + disable_version?: boolean | undefined; }; /** @internal */ @@ -1296,6 +1321,7 @@ export const UpdatePlanParams$outboundSchema: z.ZodMiniType< version: z.optional(z.number()), archived: z._default(z.boolean(), false), newPlanId: z.optional(z.string()), + disableVersion: z.optional(z.boolean()), }), z.transform((v) => { return remap$(v, { @@ -1305,6 +1331,7 @@ export const UpdatePlanParams$outboundSchema: z.ZodMiniType< freeTrial: "free_trial", createInStripe: "create_in_stripe", newPlanId: "new_plan_id", + disableVersion: "disable_version", }); }), ); @@ -1496,6 +1523,49 @@ export function updatePlanResetResponseFromJSON( ); } +/** @internal */ +export const UpdatePlanToResponse$inboundSchema: z.ZodMiniType< + UpdatePlanToResponse, + unknown +> = smartUnion([types.number(), types.string()]); + +export function updatePlanToResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => UpdatePlanToResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'UpdatePlanToResponse' from JSON`, + ); +} + +/** @internal */ +export const UpdatePlanTierResponse$inboundSchema: z.ZodMiniType< + UpdatePlanTierResponse, + unknown +> = z.pipe( + z.object({ + to: smartUnion([types.number(), types.string()]), + amount: types.number(), + flat_amount: types.optional(types.number()), + }), + z.transform((v) => { + return remap$(v, { + "flat_amount": "flatAmount", + }); + }), +); + +export function updatePlanTierResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => UpdatePlanTierResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'UpdatePlanTierResponse' from JSON`, + ); +} + /** @internal */ export const UpdatePlanTierBehaviorResponse$inboundSchema: z.ZodMiniType< UpdatePlanTierBehaviorResponse, @@ -1521,7 +1591,9 @@ export const UpdatePlanItemPriceResponse$inboundSchema: z.ZodMiniType< > = z.pipe( z.object({ amount: types.optional(types.number()), - tiers: types.optional(z.array(types.nullable(z.any()))), + tiers: types.optional( + z.array(z.lazy(() => UpdatePlanTierResponse$inboundSchema)), + ), tier_behavior: types.optional(UpdatePlanTierBehaviorResponse$inboundSchema), interval: UpdatePlanPriceItemIntervalResponse$inboundSchema, interval_count: types.optional(types.number()), diff --git a/packages/sdk/src/sdk/billing.ts b/packages/sdk/src/sdk/billing.ts index cd86ce3ce..52d94e151 100644 --- a/packages/sdk/src/sdk/billing.ts +++ b/packages/sdk/src/sdk/billing.ts @@ -87,7 +87,7 @@ 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":1780512803523,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781722403523,"plans":[{"planId":"pro_plan"}]}] }); + * const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1781115250101,"plans":[{"planId":"trial_plan"}]},{"startsAt":1782324850101,"plans":[{"planId":"pro_plan"}]}] }); * ``` * * @param customerId - The ID of the customer to create the schedule for. diff --git a/packages/sdk/src/sdk/features.ts b/packages/sdk/src/sdk/features.ts index 436c04f1b..27b4e859c 100644 --- a/packages/sdk/src/sdk/features.ts +++ b/packages/sdk/src/sdk/features.ts @@ -39,7 +39,10 @@ export class Features extends ClientSDK { * @param type - The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. * @param consumable - Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features. (optional) * @param display - Singular and plural display names for the feature in your user interface. (optional) - * @param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. (optional) + * @param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. (optional) + * @param modelMarkups - Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. (optional) + * @param defaultMarkup - Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. (optional) + * @param providerMarkups - Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. (optional) * @param featureId - The ID of the feature to create. * * @returns The created feature object. @@ -120,7 +123,10 @@ export class Features extends ClientSDK { * @param type - The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system. (optional) * @param consumable - Whether this feature is consumable. A consumable feature is one that periodically resets and is consumed rather than allocated (like credits, API requests, etc.). Applicable only for 'metered' features. (optional) * @param display - Singular and plural display names for the feature in your user interface. (optional) - * @param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features. (optional) + * @param creditSchema - A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead. (optional) + * @param modelMarkups - Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration. (optional) + * @param defaultMarkup - Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free. (optional) + * @param providerMarkups - Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id. (optional) * @param archived - Whether the feature is archived. Archived features are hidden from the dashboard. (optional) * @param featureId - The ID of the feature to update. * @param newFeatureId - The new ID of the feature. Feature ID can only be updated if it's not being used by any customers. (optional) diff --git a/packages/sdk/src/sdk/sdk.ts b/packages/sdk/src/sdk/sdk.ts index da01a652b..5467a329e 100644 --- a/packages/sdk/src/sdk/sdk.ts +++ b/packages/sdk/src/sdk/sdk.ts @@ -4,6 +4,7 @@ import { batchTrack } from "../funcs/batch-track.js"; import { check } from "../funcs/check.js"; +import { trackTokens } from "../funcs/track-tokens.js"; import { track } from "../funcs/track.js"; import { ClientSDK, RequestOptions } from "../lib/sdks.js"; import * as models from "../models/index.js"; @@ -153,6 +154,50 @@ export class Autumn extends ClientSDK { )); } + /** + * Records AI token usage for a customer and returns the updated AI credit balance. + * + * Use this after an LLM request when you have input and output token counts. Autumn converts token usage to a dollar amount using the configured model pricing and markup, then tracks that value against the customer's AI credit system. + * + * @example + * ```typescript + * // Track one LLM response + * const response = await client.trackTokens({ + * + * customerId: "cus_123", + * featureId: "ai_credits", + * modelId: "anthropic/claude-sonnet-4-20250514", + * inputTokens: 1000, + * outputTokens: 500, + * }); + * ``` + * + * @param customerId - The ID of the customer. + * @param entityId - The ID of the entity for entity-scoped balances. (optional) + * @param featureId - The ID of the AI credit system feature. Auto-detected from the customer's entitlements if omitted — only required when a customer has multiple AI credit systems. (optional) + * @param modelId - The AI model as '/' (e.g. 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). The provider is the first path segment and must match a provider + model key in models.dev. + * @param inputTokens - Number of non-cached text input tokens consumed. Exclusive of cache and audio token pools. + * @param outputTokens - Number of text output tokens consumed. Exclusive of the reasoning and audio output pools. + * @param cacheReadTokens - Number of cached input tokens read. (optional) + * @param cacheWriteTokens - Number of input tokens written to the cache. (optional) + * @param audioInputTokens - Number of audio input tokens consumed. (optional) + * @param audioOutputTokens - Number of audio output tokens generated. (optional) + * @param reasoningTokens - Number of reasoning tokens generated. (optional) + * @param properties - Additional properties to attach to this usage event. (optional) + * + * @returns The dollar value recorded and the updated AI credit system balance. If Autumn is experiencing degraded service from a downstream provider, the API may return 202 after accepting the token usage event for replay so it can be tracked as soon as the service is restored. + */ + async trackTokens( + request: models.TrackTokensParams, + options?: RequestOptions, + ): Promise { + return unwrapAsync(trackTokens( + this, + request, + options, + )); + } + /** * Enqueue up to 1000 usage events for asynchronous processing. Items are validated synchronously up front; validated items are then enqueued via SQS for background deduction by workers. The response returns 202 immediately and does not include balance information. On partial enqueue failure (some items fail to enqueue, others succeed), the endpoint still returns 202 and logs the failures server-side; clients should NOT retry, because retrying re-enqueues the already-succeeded items. A 503 is returned only when zero items were successfully enqueued (queue entirely unavailable) — that case is safe to retry. */ diff --git a/scripts/dev.ts b/scripts/dev.ts index 633f73adc..e6bac71f9 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -67,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; @@ -180,10 +156,6 @@ async function startDev() { // Use cmd on Windows, sh on Unix const isWindows = process.platform === "win32"; - const triggerDevVersion = getPackageDependencyVersion({ - projectRoot, - packageName: "trigger.dev", - }); let shellArgs: string[]; if (serverOnly) { @@ -227,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"); 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/diffListEntitiesV2Responses.ts b/server/experiments/diffListEntitiesV2Responses.ts new file mode 100644 index 000000000..e0bbe1ef0 --- /dev/null +++ b/server/experiments/diffListEntitiesV2Responses.ts @@ -0,0 +1,177 @@ +// Run with `CHECK_ORG_ID=... CHECK_CUSTOMER_ID=... CHECK_LIMIT=200 bun run experiments/diffListEntitiesV2Responses.ts` +import { AppEnv, type CusProductStatus, type SubjectQueryRow } from "@autumn/shared"; +import { sql } from "drizzle-orm"; +import { initDrizzle } from "../src/db/initDrizzle.js"; +import { RELEVANT_STATUSES } from "../src/internal/customers/cusProducts/CusProductService.js"; +import { getFullSubjectRowsQuery } from "../src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.js"; +import { mergeEntityAndCustomerSubjectRows } from "../src/internal/customers/repos/getFullSubject/mergeEntityAndCustomerSubjectRows.js"; +import { getCursorPaginatedEntitySubjectsQuery } from "../src/internal/entities/repos/cursorListEntitiesQuery.js"; +import { getCustomerLevelSubjectRowsQuery } from "../src/internal/entities/repos/customerLevelSubjectsQuery.js"; + +const ORG_ID = process.env.CHECK_ORG_ID as string; +const CUSTOMER_ID = process.env.CHECK_CUSTOMER_ID as string; +const LIMIT = Number(process.env.CHECK_LIMIT || 200); +const ENV = AppEnv.Live; + +const getCombinedQuery = ({ inStatuses }: { inStatuses: CusProductStatus[] }) => { + const customerFilter = CUSTOMER_ID ? sql`AND c.id = ${CUSTOMER_ID}` : sql``; + const leadingCtes = sql` + WITH entity_records AS ( + SELECT e.* + FROM entities e + JOIN customers c ON c.internal_id = e.internal_customer_id + WHERE e.org_id = ${ORG_ID} AND e.env = ${ENV} + AND c.org_id = ${ORG_ID} AND c.env = ${ENV} + ${customerFilter} + ORDER BY e.created_at DESC, e.id DESC + LIMIT ${LIMIT + 1} + ), + subject_records AS ( + SELECT er.internal_id AS subject_key, er.internal_customer_id, er.internal_id AS internal_entity_id, + ROW_NUMBER() OVER (ORDER BY er.created_at DESC, er.id DESC) AS subject_order + FROM entity_records er + ) + `; + return getFullSubjectRowsQuery({ + leadingCtes, + inStatuses, + includeInvoices: false, + includeEntityAggregations: false, + }); +}; + +const stableStringify = (value: unknown): string => { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value && typeof value === "object") { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`); + return `{${entries.join(",")}}`; + } + return JSON.stringify(value); +}; + +const sortByKey = (rows: Record[], key: string) => + [...rows].sort((a, b) => (String(a[key]) < String(b[key]) ? -1 : 1)); + +// order-insensitive fields: combined query has no deterministic ORDER BY here +const UNORDERED_FIELDS: Record = { + customer_entitlements: "id", + customer_prices: "id", + subscriptions: "stripe_id", + entitlements: "id", + rollovers: "id", + replaceables: "id", +}; +const ORDERED_FIELDS = [ + "customer", + "entity", + "customer_products", + "extra_customer_entitlements", + "products", + "prices", + "free_trials", +]; + +const main = async () => { + const { db } = initDrizzle(); + const inStatuses = RELEVANT_STATUSES; + + const combinedRows = (await db.execute( + getCombinedQuery({ inStatuses }), + )) as unknown as SubjectQueryRow[]; + + const entityRows = (await db.execute( + getCursorPaginatedEntitySubjectsQuery({ + orgId: ORG_ID, + env: ENV, + limit: LIMIT, + cursor: null, + inStatuses, + customerId: CUSTOMER_ID || undefined, + }), + )) as unknown as SubjectQueryRow[]; + + const internalCustomerIds = [ + ...new Set(entityRows.map((row) => row.customer.internal_id)), + ]; + const customerRows = + internalCustomerIds.length > 0 + ? ((await db.execute( + getCustomerLevelSubjectRowsQuery({ + orgId: ORG_ID, + env: ENV, + internalCustomerIds, + inStatuses, + }), + )) as unknown as SubjectQueryRow[]) + : []; + const customerRowsByInternalId = new Map( + customerRows.map((row) => [row.customer.internal_id, row]), + ); + + const mergedRows = entityRows.map((entityRow) => + mergeEntityAndCustomerSubjectRows({ + entityRow, + customerRow: customerRowsByInternalId.get(entityRow.customer.internal_id), + }), + ); + + console.log(`combined: ${combinedRows.length} rows, merged: ${mergedRows.length} rows`); + if (combinedRows.length !== mergedRows.length) throw new Error("row count mismatch"); + + const classifiedFields = new Set([ + ...ORDERED_FIELDS, + ...Object.keys(UNORDERED_FIELDS), + ]); + const unclassifiedFields = [ + ...new Set( + [...combinedRows, ...mergedRows].flatMap((row) => + Object.keys(row as Record), + ), + ), + ].filter((field) => !classifiedFields.has(field)); + if (unclassifiedFields.length > 0) { + console.log( + `unclassified row fields (add to ORDERED_FIELDS or UNORDERED_FIELDS): ${unclassifiedFields.join(", ")}`, + ); + process.exit(1); + } + + let mismatches = 0; + for (let i = 0; i < combinedRows.length; i++) { + const combined = combinedRows[i] as unknown as Record; + const merged = mergedRows[i] as unknown as Record; + + for (const field of ORDERED_FIELDS) { + const left = stableStringify(combined[field] ?? null); + const right = stableStringify(merged[field] ?? null); + if (left !== right) { + mismatches++; + console.log(`row ${i} entity=${(combined.entity as { id?: string })?.id} ORDERED field "${field}" differs`); + if (mismatches <= 3) { + console.log(` combined: ${left.slice(0, 500)}`); + console.log(` merged: ${right.slice(0, 500)}`); + } + } + } + for (const [field, key] of Object.entries(UNORDERED_FIELDS)) { + const left = stableStringify(sortByKey((combined[field] as Record[]) ?? [], key)); + const right = stableStringify(sortByKey((merged[field] as Record[]) ?? [], key)); + if (left !== right) { + mismatches++; + console.log(`row ${i} entity=${(combined.entity as { id?: string })?.id} UNORDERED field "${field}" differs`); + if (mismatches <= 3) { + console.log(` combined: ${left.slice(0, 500)}`); + console.log(` merged: ${right.slice(0, 500)}`); + } + } + } + } + + console.log(mismatches === 0 ? "ALL ROWS IDENTICAL" : `${mismatches} field mismatches`); + process.exit(mismatches === 0 ? 0 : 1); +}; + +await main(); 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/explainListEntitiesV2.ts b/server/experiments/explainListEntitiesV2.ts new file mode 100644 index 000000000..157e56ca9 --- /dev/null +++ b/server/experiments/explainListEntitiesV2.ts @@ -0,0 +1,181 @@ +import { AppEnv, type CusProductStatus } from "@autumn/shared"; +import { type SQL, sql } from "drizzle-orm"; +import { RELEVANT_STATUSES } from "../src/internal/customers/cusProducts/CusProductService.js"; +import { getFullSubjectRowsQuery } from "../src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.js"; +import { getCursorPaginatedEntitySubjectsQuery } from "../src/internal/entities/repos/cursorListEntitiesQuery.js"; +import { getCustomerLevelSubjectRowsQuery } from "../src/internal/entities/repos/customerLevelSubjectsQuery.js"; +import { + initDrizzle, + prodTestCustomerId, + prodTestOrgId, +} from "./experimentEnv"; + +// Run with `bun run experiments/explainListEntitiesV2.ts` (flags: --explain, --skip-old) +const ORG_ID = prodTestOrgId; +const ENV = AppEnv.Live; +const CUSTOMER_ID = prodTestCustomerId; +const LIMIT = Number(process.env.LIMIT || 1000); + +/** Pre-split combined hydration: same page CTE, hydrated without entityScopedOnly. */ +const getCombinedEntityPageQuery = ({ + orgId, + env, + customerId, + limit, + inStatuses, +}: { + orgId: string; + env: AppEnv; + customerId?: string; + limit: number; + inStatuses: CusProductStatus[]; +}) => { + const customerFilter = customerId ? sql`AND c.id = ${customerId}` : sql``; + + const leadingCtes = sql` + WITH entity_records AS ( + SELECT e.* + FROM entities e + JOIN customers c + ON c.internal_id = e.internal_customer_id + WHERE e.org_id = ${orgId} + AND e.env = ${env} + AND c.org_id = ${orgId} + AND c.env = ${env} + ${customerFilter} + ORDER BY e.created_at DESC, e.id DESC + LIMIT ${limit + 1} + ), + + subject_records AS ( + SELECT + er.internal_id AS subject_key, + er.internal_customer_id, + er.internal_id AS internal_entity_id, + ROW_NUMBER() OVER (ORDER BY er.created_at DESC, er.id DESC) AS subject_order + FROM entity_records er + ) + `; + + return getFullSubjectRowsQuery({ + leadingCtes, + inStatuses, + includeInvoices: false, + includeEntityAggregations: false, + }); +}; + +const printExplainPlan = async ({ + db, + query, +}: { + db: ReturnType["db"]; + query: SQL; +}) => { + const explainResult = await db.execute( + sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${query}`, + ); + + for (const row of explainResult) { + console.log((row as Record)["QUERY PLAN"]); + } +}; + +const runMeasuredQuery = async ({ + db, + label, + query, + withExplain, +}: { + db: ReturnType["db"]; + label: string; + query: SQL; + withExplain: boolean; +}) => { + console.log(`\n=== ${label} ===\n`); + + const startedAt = performance.now(); + const result = await db.execute(query); + const elapsedMilliseconds = performance.now() - startedAt; + + console.log(`Rows returned: ${result.length}`); + console.log(`Wall-clock time: ${elapsedMilliseconds.toFixed(2)}ms\n`); + + if (withExplain) { + await printExplainPlan({ + db, + query, + }); + } +}; + +const main = async () => { + const { db } = initDrizzle(); + const inStatuses = RELEVANT_STATUSES; + const withExplain = process.argv.includes("--explain"); + const skipOld = process.argv.includes("--skip-old"); + + console.log("=== LIST ENTITIES V2 SPLIT HYDRATION EXPERIMENT ===\n"); + console.log( + JSON.stringify( + { orgId: ORG_ID, env: ENV, customerId: CUSTOMER_ID, limit: LIMIT }, + null, + 2, + ), + ); + + const customerRows = await db.execute( + sql`SELECT internal_id FROM customers + WHERE org_id = ${ORG_ID} AND env = ${ENV} AND id = ${CUSTOMER_ID}`, + ); + const internalCustomerId = (customerRows[0] as { internal_id?: string }) + ?.internal_id; + if (!internalCustomerId) { + throw new Error(`Customer ${CUSTOMER_ID} not found in org ${ORG_ID}`); + } + + if (!skipOld) { + await runMeasuredQuery({ + db, + label: "OLD COMBINED QUERY (pre-split hydration)", + query: getCombinedEntityPageQuery({ + orgId: ORG_ID, + env: ENV, + customerId: CUSTOMER_ID, + limit: LIMIT, + inStatuses, + }), + withExplain, + }); + } + + await runMeasuredQuery({ + db, + label: "NEW QUERY A (entity-scoped page hydration)", + query: getCursorPaginatedEntitySubjectsQuery({ + orgId: ORG_ID, + env: ENV, + limit: LIMIT, + cursor: null, + inStatuses, + customerId: CUSTOMER_ID, + }), + withExplain, + }); + + await runMeasuredQuery({ + db, + label: "NEW QUERY B (customer-level hydration, once per customer)", + query: getCustomerLevelSubjectRowsQuery({ + orgId: ORG_ID, + env: ENV, + internalCustomerIds: [internalCustomerId], + inStatuses, + }), + withExplain, + }); + + 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/src/_luaScriptsV2/fullSubjectDeduction/deductFromRolloversV2.lua b/server/src/_luaScriptsV2/fullSubjectDeduction/deductFromRolloversV2.lua index 7ee5f7f47..60b8acdc8 100644 --- a/server/src/_luaScriptsV2/fullSubjectDeduction/deductFromRolloversV2.lua +++ b/server/src/_luaScriptsV2/fullSubjectDeduction/deductFromRolloversV2.lua @@ -74,9 +74,15 @@ local function deduct_from_rollovers(params) local rollover_id = rollover_obj.id local credit_cost = rollover_obj.credit_cost - if is_nil(credit_cost) or credit_cost == 0 then + if is_nil(credit_cost) then credit_cost = 1 end + if credit_cost == 0 then + -- Zero credit cost (e.g. -100% markup AI model): usage is free, leave rollovers untouched. + logger.log(" Rollover %s credit_cost=0 - free deduction, skipping", rollover_id) + remaining = 0 + break + end local rollover_data = context.rollovers[rollover_id] if not rollover_data then diff --git a/server/src/_luaScriptsV2/fullSubjectDeduction/runDeductionOnContextV2.lua b/server/src/_luaScriptsV2/fullSubjectDeduction/runDeductionOnContextV2.lua index e092ee3a3..1c590a479 100644 --- a/server/src/_luaScriptsV2/fullSubjectDeduction/runDeductionOnContextV2.lua +++ b/server/src/_luaScriptsV2/fullSubjectDeduction/runDeductionOnContextV2.lua @@ -50,7 +50,7 @@ local function process_deduction_pass(params) local ent_id = ent_obj.customer_entitlement_id local credit_cost = ent_obj.credit_cost local ent_feature_id = ent_obj.feature_id - if credit_cost == cjson.null or credit_cost == nil or credit_cost == 0 then + if credit_cost == cjson.null or credit_cost == nil then credit_cost = 1 end @@ -91,6 +91,11 @@ local function process_deduction_pass(params) if not should_process then logger.log("%s skipping %s - usage_allowed=false or not in context", pass_name, ent_id) + elseif credit_cost == 0 then + -- Zero credit cost (e.g. -100% markup AI model): the usage is free. + -- Consume the requested amount without touching any balance. + logger.log("%s ent %s credit_cost=0 - free deduction, no balance change", pass_name, ent_id) + remaining_amount = 0 else local deducted = deduct_from_main_balance({ context = context, diff --git a/server/src/db/dbUtils.ts b/server/src/db/dbUtils.ts index 2cfc08291..fc0ef5fe0 100644 --- a/server/src/db/dbUtils.ts +++ b/server/src/db/dbUtils.ts @@ -27,6 +27,7 @@ const TRANSIENT_DB_ERROR_MESSAGES = new Set([ "timeout exceeded when trying to connect", "Query read timeout", "Connection terminated due to connection timeout", + "Connection terminated unexpectedly", "canceling statement due to lock timeout", "canceling statement due to statement timeout", ]); diff --git a/server/src/db/initDrizzle.ts b/server/src/db/initDrizzle.ts index a1331bda3..e024552f5 100644 --- a/server/src/db/initDrizzle.ts +++ b/server/src/db/initDrizzle.ts @@ -8,6 +8,7 @@ import { instrumentDrizzleClient } from "@kubiks/otel-drizzle"; import type { SQLWrapper } from "drizzle-orm"; import { drizzle } from "drizzle-orm/node-postgres"; import pg, { type PoolConfig } from "pg"; +import { logger } from "../external/logtail/logtailUtils.js"; import { otelConfig } from "../utils/otel/otelConfig.js"; import { attachPoolErrorHandlers, registerPool } from "./pgPoolMonitor.js"; @@ -97,22 +98,72 @@ export const initDrizzle = ({ // Strict latency limits in prod; relaxed locally so dev pool warm-up doesn't kill tests. const isProd = process.env.NODE_ENV === "production"; +const poolMaxFromEnv = ({ + envVar, + fallback, +}: { + envVar: string; + fallback: number; +}): number => { + const parsed = Number(process.env[envVar]); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +}; + +const PGBOUNCER_MAX_CLIENT_CONN = 7_600; +const BUDGETED_FLEET_PROCESSES = 150; +const BUDGETED_NON_SERVER_CONNECTIONS = 80; +const POOL_BUDGET_HEADROOM = 0.85; + +const PROD_POOL_MAX = { + critical: 22, + general: 14, + replica: 6, +}; + +const criticalPoolMax = poolMaxFromEnv({ + envVar: "CRITICAL_DB_POOL_MAX", + fallback: isProd ? PROD_POOL_MAX.critical : 10, +}); +const generalPoolMax = poolMaxFromEnv({ + envVar: "GENERAL_DB_POOL_MAX", + fallback: isProd ? PROD_POOL_MAX.general : 10, +}); +const replicaPoolMax = poolMaxFromEnv({ + envVar: "REPLICA_DB_POOL_MAX", + fallback: PROD_POOL_MAX.replica, +}); + +const budgetedFleetConnections = + BUDGETED_FLEET_PROCESSES * + (criticalPoolMax + generalPoolMax + replicaPoolMax) + + BUDGETED_NON_SERVER_CONNECTIONS; + +if ( + budgetedFleetConnections > + PGBOUNCER_MAX_CLIENT_CONN * POOL_BUDGET_HEADROOM +) { + logger.warn( + `[initDrizzle] pool budget (${budgetedFleetConnections}) exceeds ${POOL_BUDGET_HEADROOM} of max_client_conn (${PGBOUNCER_MAX_CLIENT_CONN}) — lower the pool maxes or raise the ceiling`, + ); +} + export const { db: dbCritical, client: clientCritical } = initDrizzle({ name: "critical", - maxConnections: isProd ? 100 : 10, + maxConnections: criticalPoolMax, connectTimeout: isProd ? 2 : 30, databaseUrl: process.env.DATABASE_CRITICAL_URL, poolConfig: { application_name: "autumn-critical", query_timeout: isProd ? 2_000 : 30_000, - // Keep 10 warm conns to avoid TLS-handshake stampedes on bursty traffic. - min: 10, + // Keep warm conns to avoid TLS-handshake stampedes on bursty traffic. + min: Math.min(10, criticalPoolMax), }, }); // -- General pool: used by all other endpoints -- export const { db: dbGeneral, client: clientGeneral } = initDrizzle({ name: "general", + maxConnections: generalPoolMax, connectTimeout: isProd ? 5 : 30, }); @@ -122,7 +173,7 @@ const replicaResult = process.env.DATABASE_REPLICA_URL ? initDrizzle({ name: "replica", replica: true, - maxConnections: 15, + maxConnections: replicaPoolMax, connectTimeout: null, }) : null; diff --git a/server/src/db/shed503OnTransientError.ts b/server/src/db/shed503OnTransientError.ts new file mode 100644 index 000000000..42c699d62 --- /dev/null +++ b/server/src/db/shed503OnTransientError.ts @@ -0,0 +1,32 @@ +import { RecaseError } from "@autumn/shared"; +import { isTransientRedisError } from "@/external/redis/utils/isTransientRedisError.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { isTransientDbError } from "./dbUtils.js"; + +export const shed503OnTransientError = async ({ + ctx, + source, + run, +}: { + ctx: AutumnContext; + source: string; + run: () => T | Promise; +}): Promise => { + try { + return await run(); + } catch (error) { + if (!(isTransientDbError({ error }) || isTransientRedisError({ error }))) { + throw error; + } + ctx.logger.warn(`[${source}] DB unavailable, shedding with 503`, { + type: `${source}_fail_open`, + error, + }); + throw new RecaseError({ + message: "Service is temporarily unavailable, please retry shortly.", + code: "service_unavailable", + statusCode: 503, + data: { reason: "critical_db_saturated" }, + }); + } +}; diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 85fa6f19a..2e76b5c22 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -988,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; @@ -1002,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); @@ -1016,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 }); @@ -1035,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); @@ -1047,6 +1053,7 @@ export class AutumnInt { migration_id: string; dry_run: boolean; lazy_run: boolean; + concurrency?: number; run_id: string; }; }, @@ -1059,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[] }> => { 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/honoMiddlewares/errorMiddleware.ts b/server/src/honoMiddlewares/errorMiddleware.ts index 6d56fdb2d..ccdb2127d 100644 --- a/server/src/honoMiddlewares/errorMiddleware.ts +++ b/server/src/honoMiddlewares/errorMiddleware.ts @@ -52,6 +52,7 @@ export const errorMiddleware = (err: Error, c: Context) => { }, ); + if (err.statusCode === 503) c.header("Retry-After", "1"); return c.json( { message: err.message, diff --git a/server/src/honoMiddlewares/errorSkipMiddleware.ts b/server/src/honoMiddlewares/errorSkipMiddleware.ts index ae76f76a1..7c53983e2 100644 --- a/server/src/honoMiddlewares/errorSkipMiddleware.ts +++ b/server/src/honoMiddlewares/errorSkipMiddleware.ts @@ -160,6 +160,7 @@ const createErrorResponse = ({ code: string; statusCode: ContentfulStatusCode; }) => { + if (statusCode === 503) c.header("Retry-After", "1"); return c.json( { message, 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/rateLimitMiddleware.ts b/server/src/honoMiddlewares/rateLimitMiddleware.ts index b3e1ff4cf..6e8c85cb0 100644 --- a/server/src/honoMiddlewares/rateLimitMiddleware.ts +++ b/server/src/honoMiddlewares/rateLimitMiddleware.ts @@ -6,6 +6,7 @@ import { setRateLimitKeyInContext, } from "@/internal/misc/rateLimiter/rateLimitFactory"; import { + getOrgAggregateType, getRateLimitType, RateLimitType, } from "../internal/misc/rateLimiter/rateLimitConfigs"; @@ -39,8 +40,31 @@ export const rateLimitMiddleware = async (c: Context, next: Next) => { // 4. Get the appropriate limiter for this type const limiter = getLimiterForType(rateLimitType); - // 5. Apply rate limiting - return await limiter(c as Context, next); + const aggregateType = getOrgAggregateType(rateLimitType); + if (!aggregateType) { + // 5. Apply rate limiting + return await limiter(c as Context, next); + } + + // 5. Org-aggregate limiter wraps the per-customer one; the key slot is + // swapped between them since keyGenerator reads it at execution time. + setRateLimitKeyInContext( + c as Context, + getRateLimitKey({ c, rateLimitType: aggregateType }), + ); + const aggregateLimiter = getLimiterForType(aggregateType); + + let innerResponse: Response | undefined; + const aggregateResponse = await aggregateLimiter( + c as Context, + async () => { + setRateLimitKeyInContext(c as Context, rateLimitKey); + innerResponse = (await limiter(c as Context, next)) ?? undefined; + }, + ); + + // hono-rate-limiter discards next()'s return, so re-surface an inner 429. + return aggregateResponse ?? innerResponse; } catch (error) { ctx.logger.error( `Error checking rate limit, error: ${error}. Bypassing for now`, diff --git a/server/src/honoUtils/HonoEnv.ts b/server/src/honoUtils/HonoEnv.ts index c7f928183..3fbefb7b5 100644 --- a/server/src/honoUtils/HonoEnv.ts +++ b/server/src/honoUtils/HonoEnv.ts @@ -74,6 +74,10 @@ export type RequestContext = { fullCustomer?: FullCustomer; rolloutSnapshot?: RolloutSnapshot; + /** Org is over its aggregate rate cap — check/track flows skip the DB and + * serve their fail-open responses (allow / SQS queue) instead. */ + orgRateLimitDegraded?: boolean; + testOptions?: { skipCacheDeletion?: boolean; skipWebhooks?: boolean; diff --git a/server/src/init.ts b/server/src/init.ts index 6d9ab9d43..254807594 100644 --- a/server/src/init.ts +++ b/server/src/init.ts @@ -66,6 +66,7 @@ const init = async ({ startupStartedAt }: { startupStartedAt: number }) => { void preWarmOrgRedisConnections({ db }).catch((error) => { logger.warn("[OrgRedis] Warmup failed", { error }); }); + await startAllEdgeConfigPolling({ logger }); await Promise.all([primeRedisMonitor(), primeRedisV2Monitor()]); startRedisMonitor(); diff --git a/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts b/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts index c927374a9..5bff44cbb 100644 --- a/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts +++ b/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts @@ -68,8 +68,7 @@ const getRedirectUriFromFields = (fields: RequestFields) => getNestedOAuthField(fields.oauth_query, "redirect_uri"); const getScopesFromFields = (fields: RequestFields) => { - const rawScope = - getString(fields.scope) ?? getNestedOAuthField(fields.oauth_query, "scope"); + const rawScope = getNestedOAuthField(fields.oauth_query, "scope"); return rawScope?.split(/\s+/).filter(Boolean) ?? null; }; @@ -80,23 +79,7 @@ const getFieldsWithScope = ({ fields: RequestFields; scope: string; }) => { - const next: RequestFields = { ...fields, scope }; - const oauthQuery = fields.oauth_query; - if (typeof oauthQuery === "string") { - try { - next.oauth_query = JSON.stringify({ ...JSON.parse(oauthQuery), scope }); - } catch { - const params = new URLSearchParams(oauthQuery); - params.set("scope", scope); - next.oauth_query = params.toString(); - } - } else if (typeof oauthQuery === "object" && oauthQuery !== null) { - next.oauth_query = { - ...(oauthQuery as Record), - scope, - }; - } - return next; + return { ...fields, scope }; }; const withScope = ({ diff --git a/server/src/internal/balances/balancesRouter.ts b/server/src/internal/balances/balancesRouter.ts index babe788a7..fce9219fc 100644 --- a/server/src/internal/balances/balancesRouter.ts +++ b/server/src/internal/balances/balancesRouter.ts @@ -10,6 +10,7 @@ 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"; import { handleUpdateBalance } from "./handlers/handleUpdateBalance.js"; // Create a Hono app for products @@ -27,6 +28,7 @@ balancesRouter.post( // Track balancesRouter.post("/events", ...handleTrack); balancesRouter.post("/track", ...handleTrack); +balancesRouter.post("/track_tokens", ...handleTrackTokens); // Check balancesRouter.post("/entitled", ...handleCheck); @@ -46,6 +48,7 @@ balancesRpcRouter.post( ); balancesRpcRouter.post("/balances.track", ...handleTrack); +balancesRpcRouter.post("/balances.track_tokens", ...handleTrackTokens); balancesRpcRouter.post("/balances.batch_track", ...handleBatchTrack); balancesRpcRouter.post("/balances.check", ...handleCheck); balancesRpcRouter.post("/balances.finalize", ...handleFinalizeLock); diff --git a/server/src/internal/balances/check/runCheckWithRollout.ts b/server/src/internal/balances/check/runCheckWithRollout.ts index eda61ab2f..17cd689b0 100644 --- a/server/src/internal/balances/check/runCheckWithRollout.ts +++ b/server/src/internal/balances/check/runCheckWithRollout.ts @@ -18,6 +18,18 @@ export const runCheckWithRollout = async ({ body: ParsedCheckParams; requiredBalance: number; }): Promise> => { + if (ctx.orgRateLimitDegraded) { + return { + checkData: null, + response: getCheckFailOpenFallback({ + ctx, + body, + requiredBalance, + error: new Error("org aggregate rate cap exceeded"), + }) as Record, + }; + } + if (!isFullSubjectRolloutEnabled({ ctx })) { return runCheckLegacyFlow({ ctx, body, requiredBalance }); } diff --git a/server/src/internal/balances/handlers/handleTrackTokens.ts b/server/src/internal/balances/handlers/handleTrackTokens.ts new file mode 100644 index 000000000..43207020f --- /dev/null +++ b/server/src/internal/balances/handlers/handleTrackTokens.ts @@ -0,0 +1,32 @@ +import { + AffectedResource, + Scopes, + TrackTokensParamsSchema, +} from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { runTrackWithRollout } from "@/internal/balances/track/runTrackWithRollout.js"; +import { getTokenTrackParams } from "@/internal/balances/track/utils/getTokenTrackParams.js"; + +export const handleTrackTokens = createRoute({ + scopes: [Scopes.Balances.Write], + body: TrackTokensParamsSchema, + resource: AffectedResource.Track, + handler: async (c) => { + const body = c.req.valid("json"); + const ctx = c.get("ctx"); + + const { body: trackBody, featureDeductions } = await getTokenTrackParams({ + ctx, + input: body, + }); + + const response = await runTrackWithRollout({ + ctx, + body: trackBody, + featureDeductions, + }); + const status = ctx.extraLogs.trackQueuedForReplay ? 202 : 200; + + return c.json(response, status); + }, +}); diff --git a/server/src/internal/balances/track/runTrackWithRollout.ts b/server/src/internal/balances/track/runTrackWithRollout.ts index 23e88a116..622590075 100644 --- a/server/src/internal/balances/track/runTrackWithRollout.ts +++ b/server/src/internal/balances/track/runTrackWithRollout.ts @@ -24,6 +24,11 @@ export const runTrackWithRollout = async ({ apiVersion?: ApiVersion; }): Promise => { if (shouldUseTrackV3({ ctx })) { + if (ctx.orgRateLimitDegraded) { + const queuedResponse = await queueTrack({ ctx, body }); + if (queuedResponse) return queuedResponse; + } + return withRedisFailOpen({ source: "runTrackWithRollout", run: () => diff --git a/server/src/internal/balances/track/utils/buildAiCreditCostProperty.ts b/server/src/internal/balances/track/utils/buildAiCreditCostProperty.ts new file mode 100644 index 000000000..673225f5d --- /dev/null +++ b/server/src/internal/balances/track/utils/buildAiCreditCostProperty.ts @@ -0,0 +1,28 @@ +import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; + +/** + * Aggregates the amounts deducted from each metered feature into a `credit_cost` + * map for an AI credit-system track, so the event records what each feature was + * charged. Returns undefined when the track isn't an AI credit deduction or + * nothing chargeable was deducted. The AI feature's own deduction is excluded so + * the map only contains the downstream metered features it consumed. + */ +export const buildAiCreditCostProperty = ({ + featureDeductions, + entries, +}: { + featureDeductions: FeatureDeduction[]; + entries: Array<{ featureId: string; amount: number }>; +}): Record | undefined => { + const aiDeduction = featureDeductions.find((d) => d.tokens); + if (!aiDeduction) return; + + const creditCost: Record = {}; + for (const { featureId, amount } of entries) { + if (featureId === aiDeduction.feature.id) continue; + if (!amount) continue; + creditCost[featureId] = (creditCost[featureId] ?? 0) + amount; + } + + return Object.keys(creditCost).length > 0 ? creditCost : undefined; +}; diff --git a/server/src/internal/balances/track/utils/getTokenTrackParams.ts b/server/src/internal/balances/track/utils/getTokenTrackParams.ts new file mode 100644 index 000000000..e75950bbd --- /dev/null +++ b/server/src/internal/balances/track/utils/getTokenTrackParams.ts @@ -0,0 +1,187 @@ +import { + ErrCode, + type Feature, + fullCustomerToCustomerEntitlements, + fullSubjectToFullCustomer, + isAiCreditSystem, + RecaseError, + type TrackParams, + type TrackTokensParams, +} from "@autumn/shared"; +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 { getModelCreditCostBreakdown } from "@/internal/features/aiCreditSystemUtils.js"; +import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js"; +import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; + +const resolveAiCreditFeatureById = ({ + features, + featureId, +}: { + features: Feature[]; + featureId: string; +}): Feature => { + const candidate = features.find((f) => f.id === featureId); + if (!candidate) { + throw new RecaseError({ + message: `Feature ${featureId} not found`, + code: ErrCode.FeatureNotFound, + statusCode: 404, + }); + } + if (!isAiCreditSystem(candidate.type)) { + throw new RecaseError({ + message: `Feature ${featureId} is not an AI credit system feature`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + return candidate; +}; + +const resolveAiCreditFeatureFromEntitlements = async ({ + ctx, + customerId, + entityId, +}: { + ctx: AutumnContext; + customerId: string; + entityId?: string; +}): Promise => { + const fullCustomer = isFullSubjectRolloutEnabled({ ctx }) + ? fullSubjectToFullCustomer({ + fullSubject: await getOrSetCachedFullSubject({ + ctx, + customerId, + entityId, + source: "resolveAiCreditFeature", + }), + }) + : await getOrSetCachedFullCustomer({ + ctx, + customerId, + entityId, + source: "resolveAiCreditFeature", + }); + + const entity = entityId + ? fullCustomer.entities?.find((e) => e.id === entityId) + : undefined; + + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer, + entity, + }); + + const aiCreditFeatures = [ + ...new Map( + cusEnts + .filter((ce) => isAiCreditSystem(ce.entitlement.feature.type)) + .map((ce) => [ce.entitlement.feature.id, ce.entitlement.feature]), + ).values(), + ]; + + if (aiCreditFeatures.length === 0) { + throw new RecaseError({ + message: "No AI credit system feature found for this customer", + code: ErrCode.FeatureNotFound, + statusCode: 404, + }); + } + if (aiCreditFeatures.length > 1) { + throw new RecaseError({ + message: + "Multiple AI credit system features found for this customer. Please specify a feature_id to disambiguate.", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + return aiCreditFeatures[0]; +}; + +export const getTokenTrackParams = async ({ + ctx, + input, +}: { + ctx: AutumnContext; + input: TrackTokensParams; +}): Promise<{ body: TrackParams; featureDeductions: FeatureDeduction[] }> => { + const aiCreditFeature = input.feature_id + ? resolveAiCreditFeatureById({ + features: ctx.features, + featureId: input.feature_id, + }) + : await resolveAiCreditFeatureFromEntitlements({ + ctx, + customerId: input.customer_id, + entityId: input.entity_id, + }); + + const pricing = await getModelCreditCostBreakdown({ + modelName: input.model_id, + 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, + tokens: { + usage: { + modelName: input.model_id, + inputTokens: input.input_tokens, + outputTokens: input.output_tokens, + }, + cost, + }, + }, + ]; + + const body: TrackParams = { + customer_id: input.customer_id, + entity_id: input.entity_id, + feature_id: aiCreditFeature.id, + value: cost, + properties: { + ...input.properties, + model: input.model_id, + input_tokens: input.input_tokens, + output_tokens: input.output_tokens, + cache_read_tokens: input.cache_read_tokens, + cache_write_tokens: input.cache_write_tokens, + audio_input_tokens: input.audio_input_tokens, + 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, + customer_data: input.customer_data, + entity_data: input.entity_data, + skip_event: input.skip_event, + }; + + return { body, featureDeductions }; +}; diff --git a/server/src/internal/balances/track/utils/runRedisTrack.ts b/server/src/internal/balances/track/utils/runRedisTrack.ts index babd09206..9f5763c81 100644 --- a/server/src/internal/balances/track/utils/runRedisTrack.ts +++ b/server/src/internal/balances/track/utils/runRedisTrack.ts @@ -15,8 +15,32 @@ import { globalSyncBatchingManagerV2 } from "../../utils/sync/SyncBatchingManage import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; import type { RolloverUpdate } from "../../utils/types/rolloverUpdate.js"; +import { buildAiCreditCostProperty } from "./buildAiCreditCostProperty.js"; import { handleRedisTrackError } from "./handleRedisTrackError.js"; +const aiCreditCostEntries = ({ + updates, + fullCustomer, +}: { + updates: Record; + fullCustomer: FullCustomer; +}): Array<{ featureId: string; amount: number }> => { + const cusEntIdToFeatureId = new Map(); + for (const cp of fullCustomer.customer_products) { + for (const ce of cp.customer_entitlements ?? []) { + cusEntIdToFeatureId.set(ce.id, ce.entitlement.feature.id); + } + } + + const entries: Array<{ featureId: string; amount: number }> = []; + for (const [cusEntId, update] of Object.entries(updates)) { + const featureId = cusEntIdToFeatureId.get(cusEntId); + if (!featureId) continue; + entries.push({ featureId, amount: update.deducted }); + } + return entries; +}; + const queueSyncItem = ({ ctx, body, @@ -111,6 +135,14 @@ export const runRedisTrack = async ({ const { updates, fullCus, rolloverUpdates } = result; + const aiCreditCost = buildAiCreditCostProperty({ + featureDeductions, + entries: aiCreditCostEntries({ updates, fullCustomer }), + }); + if (aiCreditCost) { + body.properties = { ...(body.properties ?? {}), credit_cost: aiCreditCost }; + } + // Queue sync and event queueSyncItem({ ctx, diff --git a/server/src/internal/balances/track/v3/runRedisTrackV3.ts b/server/src/internal/balances/track/v3/runRedisTrackV3.ts index 494a393b6..fa7d99ef8 100644 --- a/server/src/internal/balances/track/v3/runRedisTrackV3.ts +++ b/server/src/internal/balances/track/v3/runRedisTrackV3.ts @@ -20,6 +20,7 @@ import { import { globalSyncBatchingManagerV3 } from "@/internal/balances/utils/sync/SyncBatchingManagerV3.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; import type { RolloverUpdate } from "../../utils/types/rolloverUpdate.js"; +import { buildAiCreditCostProperty } from "../utils/buildAiCreditCostProperty.js"; import { handleRedisTrackErrorV3 } from "./handleRedisTrackErrorV3.js"; const queueSyncItem = ({ @@ -148,6 +149,17 @@ export const runRedisTrackV3 = async ({ mutationLogs, }); + const aiCreditCost = buildAiCreditCostProperty({ + featureDeductions, + entries: deductions.map((d) => ({ + featureId: d.feature_id, + amount: d.value ?? 0, + })), + }); + if (aiCreditCost) { + body.properties = { ...(body.properties ?? {}), credit_cost: aiCreditCost }; + } + queueEvent({ ctx, body, fullSubject, deductions, internalProductId }); const { balance, balances } = await deductionToTrackResponseV2({ diff --git a/server/src/internal/balances/utils/deduction/computeCreditCosts.ts b/server/src/internal/balances/utils/deduction/computeCreditCosts.ts new file mode 100644 index 000000000..998f12a97 --- /dev/null +++ b/server/src/internal/balances/utils/deduction/computeCreditCosts.ts @@ -0,0 +1,53 @@ +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"; + +const DEFAULT_CREDIT_COST = 1; + +export type CreditCostLookup = (entitlementId: string) => number; + +/** Per-entitlement credit cost lookup. Pure schema math — no I/O. */ +export const computeCreditCosts = ({ + cusEnts, + deduction, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + deduction: FeatureDeduction; +}): CreditCostLookup => { + const costMap = new Map(); + + 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; + } + + 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, DEFAULT_CREDIT_COST); + } + } + + return (entitlementId) => costMap.get(entitlementId) ?? DEFAULT_CREDIT_COST; +}; diff --git a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts index f89bb8c3a..bdb599fbf 100644 --- a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts +++ b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts @@ -15,7 +15,7 @@ import { import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js"; import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; -import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; +import { computeCreditCosts } from "./computeCreditCosts.js"; import type { CustomerEntitlementDeduction, DeductionOptions, @@ -68,7 +68,7 @@ export const prepareFeatureDeduction = ({ for (const rf of relevantFeatures) { const { unlimited: featureUnlimited } = getUnlimitedAndUsageAllowed({ cusEnts, - internalFeatureId: rf.internal_id!, + internalFeatureId: rf.internal_id, }); if (featureUnlimited) { @@ -101,14 +101,12 @@ export const prepareFeatureDeduction = ({ .map((ce) => ce.entitlement.feature.id), ); + const getCreditCostForEnt = computeCreditCosts({ cusEnts, deduction }); + // Build input for each customer entitlement const customerEntitlementDeductions: CustomerEntitlementDeduction[] = cusEnts.map((ce) => { - const creditCost = getCreditCost({ - featureId: feature.id, - creditSystem: ce.entitlement.feature, - }); - + const creditCost = getCreditCostForEnt(ce.id); const maxOverage = getMaxOverage({ cusEnt: ce }); const isFreeAllocated = @@ -148,10 +146,7 @@ export const prepareFeatureDeduction = ({ // Collect and sort rollovers by expires_at (oldest first), including credit_cost from parent entitlement const sortedRollovers = cusEnts .flatMap((ce) => { - const creditCost = getCreditCost({ - featureId: feature.id, - creditSystem: ce.entitlement.feature, - }); + const creditCost = getCreditCostForEnt(ce.id); return (ce.rollovers || []).map((r) => ({ ...r, credit_cost: creditCost, diff --git a/server/src/internal/balances/utils/deductionV2/buildUnlimitedPlanMutationLog.ts b/server/src/internal/balances/utils/deductionV2/buildUnlimitedPlanMutationLog.ts new file mode 100644 index 000000000..36c3cb53b --- /dev/null +++ b/server/src/internal/balances/utils/deductionV2/buildUnlimitedPlanMutationLog.ts @@ -0,0 +1,37 @@ +import type { FullCusEntWithFullCusProduct } from "@autumn/shared"; +import type { MutationLogItem } from "../types/mutationLogItem.js"; + +/** + * Attribute a track event to an unlimited plan even though we skip the actual + * deduction. Without this, resolveInternalProductIdForEvent gets an empty + * mutation log and the event lands in "No plan". Returns null when there is no + * unlimited entitlement to attribute to, or the resolved delta is zero. + */ +export const buildUnlimitedPlanMutationLog = ({ + unlimitedCusEnt, + toDeduct, + fallbackDeduction, + entityId, +}: { + unlimitedCusEnt: FullCusEntWithFullCusProduct | undefined; + toDeduct: number | null | undefined; + fallbackDeduction: number | null | undefined; + entityId?: string | null; +}): MutationLogItem | null => { + if (!unlimitedCusEnt) return null; + + const syntheticDelta = -(toDeduct ?? fallbackDeduction ?? 1); + if (syntheticDelta === 0) return null; + + return { + target_type: "customer_entitlement", + customer_entitlement_id: unlimitedCusEnt.id, + rollover_id: null, + entity_id: entityId ?? null, + credit_cost: 1, + balance_delta: syntheticDelta, + adjustment_delta: 0, + usage_delta: 0, + value_delta: 0, + }; +}; diff --git a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts index 9f025e7fb..99ebecc17 100644 --- a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts @@ -17,6 +17,7 @@ import type { DeductionUpdate } from "../types/deductionUpdate.js"; import type { FeatureDeduction } from "../types/featureDeduction.js"; import type { MutationLogItem } from "../types/mutationLogItem.js"; import { applyDeductionUpdateToFullSubject } from "./applyDeductionUpdateToFullSubject.js"; +import { buildUnlimitedPlanMutationLog } from "./buildUnlimitedPlanMutationLog.js"; import { applyRolloverUpdatesToFullSubject } from "./applyRolloverUpdatesToFullSubject.js"; import { logDeductionUpdatesV2 } from "./logDeductionUpdatesV2.js"; import { mutationLogsToFeaturesV2 } from "./mutationLogsToFeaturesV2.js"; @@ -130,24 +131,14 @@ export const executePostgresDeductionV2 = async ({ redisInstance: ctx.redisV2, }); } - // Attribute the event to the unlimited plan even though we skip - // the actual deduction. Without this, resolveInternalProductIdForEvent - // gets an empty mutation log and the event lands in "No plan". - if (unlimitedCusEnt) { - const syntheticDelta = -(toDeduct ?? deduction.deduction ?? 1); - if (syntheticDelta !== 0) { - allMutationLogs.push({ - target_type: "customer_entitlement", - customer_entitlement_id: unlimitedCusEnt.id, - rollover_id: null, - entity_id: entityId ?? null, - credit_cost: 1, - balance_delta: syntheticDelta, - adjustment_delta: 0, - usage_delta: 0, - value_delta: 0, - }); - } + const unlimitedPlanLog = buildUnlimitedPlanMutationLog({ + unlimitedCusEnt, + toDeduct, + fallbackDeduction: deduction.deduction, + entityId, + }); + if (unlimitedPlanLog) { + allMutationLogs.push(unlimitedPlanLog); } continue; } diff --git a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts index a9501a6e4..8d1d2f11e 100644 --- a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts @@ -29,6 +29,7 @@ import type { LuaDeductionResult } from "../types/redisDeductionResult.js"; import type { RolloverUpdate } from "../types/rolloverUpdate.js"; import { applyDeductionUpdateToFullSubject } from "./applyDeductionUpdateToFullSubject.js"; import { applyRolloverUpdatesToFullSubject } from "./applyRolloverUpdatesToFullSubject.js"; +import { buildUnlimitedPlanMutationLog } from "./buildUnlimitedPlanMutationLog.js"; import { logDeductionUpdatesV2 } from "./logDeductionUpdatesV2.js"; import { mutationLogsToFeaturesV2 } from "./mutationLogsToFeaturesV2.js"; import { normalizeDeductionSyncStateV2 } from "./normalizeDeductionSyncStateV2.js"; @@ -141,24 +142,14 @@ export const executeRedisDeductionV2 = async ({ redisInstance: redisInstance ?? ctx.redisV2, }); } - // Attribute the event to the unlimited plan even though we skip - // the actual deduction. Without this, resolveInternalProductIdForEvent - // gets an empty mutation log and the event lands in "No plan". - if (unlimitedCusEnt) { - const syntheticDelta = -(toDeduct ?? deduction.deduction ?? 1); - if (syntheticDelta !== 0) { - allMutationLogs.push({ - target_type: "customer_entitlement", - customer_entitlement_id: unlimitedCusEnt.id, - rollover_id: null, - entity_id: entityId ?? null, - credit_cost: 1, - balance_delta: syntheticDelta, - adjustment_delta: 0, - usage_delta: 0, - value_delta: 0, - }); - } + const unlimitedPlanLog = buildUnlimitedPlanMutationLog({ + unlimitedCusEnt, + toDeduct, + fallbackDeduction: deduction.deduction, + entityId, + }); + if (unlimitedPlanLog) { + allMutationLogs.push(unlimitedPlanLog); } continue; } diff --git a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts index 4b550de10..e7c2502ac 100644 --- a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts @@ -17,7 +17,7 @@ import { import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js"; import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; -import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; +import { computeCreditCosts } from "../deduction/computeCreditCosts.js"; import type { CustomerEntitlementDeduction, DeductionOptions, @@ -115,12 +115,14 @@ export const prepareFeatureDeductionV2 = ({ .map((customerEntitlement) => customerEntitlement.entitlement.feature.id), ); + const getCreditCostForEnt = computeCreditCosts({ + cusEnts: customerEntitlements, + deduction, + }); + const customerEntitlementDeductions: CustomerEntitlementDeduction[] = customerEntitlements.map((customerEntitlement) => { - const creditCost = getCreditCost({ - featureId: feature.id, - creditSystem: customerEntitlement.entitlement.feature, - }); + const creditCost = getCreditCostForEnt(customerEntitlement.id); const maxOverage = getMaxOverage({ cusEnt: customerEntitlement, @@ -162,26 +164,22 @@ export const prepareFeatureDeductionV2 = ({ }; }); - const sortedRollovers = customerEntitlements - .flatMap((customerEntitlement) => { - const creditCost = getCreditCost({ - featureId: feature.id, - creditSystem: customerEntitlement.entitlement.feature, - }); + const rolloverArrays = customerEntitlements.map((customerEntitlement) => { + const creditCost = getCreditCostForEnt(customerEntitlement.id); + return (customerEntitlement.rollovers || []).map((rollover) => ({ + ...rollover, + credit_cost: creditCost, + })); + }); - return (customerEntitlement.rollovers || []).map((rollover) => ({ - ...rollover, - credit_cost: creditCost, - })); - }) - .sort((left, right) => { - if (left.expires_at && right.expires_at) { - return left.expires_at - right.expires_at; - } - if (left.expires_at && !right.expires_at) return -1; - if (!left.expires_at && right.expires_at) return 1; - return 0; - }); + const sortedRollovers = rolloverArrays.flat().sort((left, right) => { + if (left.expires_at && right.expires_at) { + return left.expires_at - right.expires_at; + } + if (left.expires_at && !right.expires_at) return -1; + if (!left.expires_at && right.expires_at) return 1; + return 0; + }); const oneDaySeconds = 24 * 60 * 60; const oneHourSeconds = 60 * 60; diff --git a/server/src/internal/balances/utils/types/featureDeduction.ts b/server/src/internal/balances/utils/types/featureDeduction.ts index b440158d1..8448112a5 100644 --- a/server/src/internal/balances/utils/types/featureDeduction.ts +++ b/server/src/internal/balances/utils/types/featureDeduction.ts @@ -1,11 +1,25 @@ import type { Feature, LockParams } from "@autumn/shared"; import type { LockReceipt } from "../lock/fetchLockReceipt.js"; + +export type TokenUsage = { + modelName: string; + inputTokens: number; + 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; + /** Present only for track_tokens deductions; standard deductions omit it. */ + tokens?: TokenDeduction; lock?: LockParams; - lockReceipt?: LockReceipt; lockReceiptKey?: string; unwindValue?: number; diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachProductContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachProductContext.ts index 41d9472af..1091aecb4 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachProductContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachProductContext.ts @@ -1,12 +1,100 @@ import type { AttachParamsV1, BillingContextOverride, + Entitlement, + FullCustomer, + FullProduct, MultiAttachParamsV0, + UpdateSubscriptionV1Params, +} from "@autumn/shared"; +import { + BillingVersion, + cusProductToProduct, + isCustomizePlanPatchStyle, + type PatchContext, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { setupPatchContext } from "@/internal/billing/v2/setup/patch"; +import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct"; +import { getEntsWithFeature } from "@/internal/products/entitlements/entitlementUtils"; import { ProductService } from "@/internal/products/ProductService"; import { setupCustomFullProduct } from "../../../setup/setupCustomFullProduct"; +const patchContextToFullProduct = ({ + ctx, + patchContext, +}: { + ctx: AutumnContext; + patchContext: PatchContext; +}): FullProduct => { + const fullProduct = cusProductToProduct({ + cusProduct: patchContext.finalCustomerProduct, + }); + + return { + ...fullProduct, + prices: [...fullProduct.prices, ...patchContext.customPrices], + entitlements: getEntsWithFeature({ + ents: [ + ...fullProduct.entitlements, + ...(patchContext.customEntitlements as Entitlement[]), + ], + features: ctx.features, + }), + }; +}; + +const setupAttachPatchProductContext = ({ + ctx, + params, + fullCustomer, + fullProduct, + currentEpochMs, +}: { + ctx: AutumnContext; + params: AttachParamsV1 | MultiAttachParamsV0["plans"][number]; + fullCustomer: FullCustomer; + fullProduct: FullProduct; + currentEpochMs?: number; +}) => { + if (!isCustomizePlanPatchStyle(params.customize)) return undefined; + + const baseCustomerProduct = initFullCustomerProduct({ + ctx, + initContext: { + fullCustomer, + fullProduct, + featureQuantities: [], + resetCycleAnchor: currentEpochMs ?? Date.now(), + freeTrial: null, + now: currentEpochMs ?? Date.now(), + billingVersion: BillingVersion.V2, + }, + }); + + const patchParams: UpdateSubscriptionV1Params = { + customer_id: fullCustomer.id ?? fullCustomer.internal_id, + plan_id: params.plan_id, + customize: params.customize, + version: params.version, + }; + + const patchContext = setupPatchContext({ + ctx, + params: patchParams, + customerProduct: baseCustomerProduct, + fullProduct, + }); + + if (!patchContext) return undefined; + + return { + fullProduct: patchContextToFullProduct({ ctx, patchContext }), + customPrices: patchContext.customPrices, + customEnts: patchContext.customEntitlements, + }; +}; + /** * Loads the product being attached, handling version and custom items params. */ @@ -14,10 +102,14 @@ export const setupAttachProductContext = async ({ ctx, params, contextOverride = {}, + fullCustomer, + currentEpochMs, }: { ctx: AutumnContext; params: AttachParamsV1 | MultiAttachParamsV0["plans"][number]; contextOverride?: BillingContextOverride; + fullCustomer?: FullCustomer; + currentEpochMs?: number; }) => { const { productContext } = contextOverride; if (productContext) return productContext; @@ -35,6 +127,20 @@ export const setupAttachProductContext = async ({ logger: ctx.logger, }); + if (fullCustomer) { + const patchProductContext = setupAttachPatchProductContext({ + ctx, + params, + fullCustomer, + fullProduct, + currentEpochMs, + }); + + if (patchProductContext) { + return patchProductContext; + } + } + // 2. Handle custom items if provided const { fullProduct: customFullProduct, 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 b2102ab76..37ba77a28 100644 --- a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts +++ b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts @@ -14,8 +14,8 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { setupAttachProductContext } from "@/internal/billing/v2/actions/attach/setup/setupAttachProductContext"; 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 { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor"; import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext"; import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext"; import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext"; @@ -131,6 +131,7 @@ export const setupImmediateMultiProductBillingContext = async ({ customize: plan.customize, version: plan.version, }, + fullCustomer, }); const { currentCustomerProduct, scheduledCustomerProduct } = 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/setup/setupCreateScheduleBillingContext.ts b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts index 165b4d389..302709dad 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts @@ -114,6 +114,8 @@ export const setupCreateScheduleBillingContext = async ({ const scheduledPhaseContexts = await setupScheduledProductsContext({ ctx, phases: futurePhases, + fullCustomer: billingContext.fullCustomer, + currentEpochMs: billingContext.currentEpochMs, }); const scheduledCustomPrices = scheduledPhaseContexts.flatMap((phase) => diff --git a/server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts b/server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts index 769e5536c..36f0ef5ed 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts @@ -1,5 +1,6 @@ import type { CreateScheduleParamsV0, + FullCustomer, ScheduledPhaseContext, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; @@ -11,9 +12,13 @@ import { validateCreateSchedulePhasePlans } from "../errors/validateCreateSchedu export const setupScheduledProductsContext = async ({ ctx, phases, + fullCustomer, + currentEpochMs, }: { ctx: AutumnContext; phases: CreateScheduleParamsV0["phases"][number][]; + fullCustomer: FullCustomer; + currentEpochMs: number; }): Promise => Promise.all( phases.map(async (phase, index) => { @@ -28,6 +33,8 @@ export const setupScheduledProductsContext = async ({ } = await setupAttachProductContext({ ctx, params: plan, + fullCustomer, + currentEpochMs, }); const featureQuantities = setupFeatureQuantitiesContext({ 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 939be3716..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, 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 5bb510c40..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"; @@ -19,7 +21,6 @@ import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullC import { setupIgnoreProrationBehavior } from "@/internal/billing/v2/setup/setupIgnoreProrationBehavior"; import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext"; import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor"; -import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling"; import { setupAttachCheckoutMode } from "../../attach/setup/setupAttachCheckoutMode"; import { setupUpdateSubscriptionIntent } from "./setupUpdateSubscriptionIntent"; import { setupUpdateSubscriptionTrialContext } from "./setupUpdateSubscriptionTrialContext"; @@ -113,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 @@ -199,8 +213,9 @@ export const setupUpdateSubscriptionBillingContext = async ({ stripeSubscriptionSchedule, stripeDiscounts, stripeCustomer, - stripeTaxRate, + stripeTaxRate: stripeTaxRate ?? inheritedStripeTaxRate, paymentMethod, + taxRateId: inheritedTaxRateId, currentEpochMs, billingCycleAnchorMs, 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/computePatchPlan/computePatchCustomerProductPlan.ts b/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts index d3a460bf1..d5d18e2fd 100644 --- a/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts +++ b/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts @@ -8,6 +8,7 @@ import { } 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 = ({ @@ -24,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, @@ -46,6 +51,7 @@ export const computePatchCustomerProductPlan = ({ customEntitlements: patchContext.customEntitlements, customFreeTrial: trialContext?.customFreeTrial, lineItems: allLineItems, + insertCustomerEntitlements: oneOffPrepaidCarryOverCustomerEntitlements, updateCustomerEntitlements: computeAnchorResetEntitlementUpdates({ updateSubscriptionContext, finalCustomerProduct, @@ -53,18 +59,31 @@ export const computePatchCustomerProductPlan = ({ } 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; } 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/utils/discounts/applyPercentOffDiscountToLineItems.ts b/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyPercentOffDiscountToLineItems.ts index 7deb995b9..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 @@ -56,7 +56,6 @@ export const applyPercentOffDiscountToLineItems = ({ const itemDiscount = new Decimal(discountableAmount) .times(percentOff) .dividedBy(100) - .round() .toNumber(); if (itemDiscount === 0) return item; @@ -77,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 9402f8a4a..6379eb6fc 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 @@ -9,11 +9,13 @@ export const filterStripeDiscountsForNextCycle = ({ currentEpochMs, nextCycleStart, discountStartMs, + hasImmediateInvoice = true, }: { stripeDiscounts: StripeDiscountWithCoupon[]; currentEpochMs: number; nextCycleStart: number; discountStartMs?: number; + hasImmediateInvoice?: boolean; }) => { return stripeDiscounts.filter((discount) => stripeDiscountAppliesToNextCycle({ @@ -21,6 +23,7 @@ export const filterStripeDiscountsForNextCycle = ({ currentEpochMs, nextCycleStart, discountStartMs, + hasImmediateInvoice, }), ); }; @@ -33,11 +36,13 @@ const stripeDiscountAppliesToNextCycle = ({ currentEpochMs, nextCycleStart, discountStartMs, + hasImmediateInvoice, }: { discount: StripeDiscountWithCoupon; currentEpochMs: number; nextCycleStart: number; discountStartMs?: number; + hasImmediateInvoice: boolean; }) => { if (discount.id) { if (discount.end == null) return true; @@ -51,7 +56,9 @@ const stripeDiscountAppliesToNextCycle = ({ } if (coupon.duration === "once") { - return false; + // A fresh once coupon hits the first invoice after it's applied — when + // nothing is invoiced immediately, that first invoice is the next cycle's. + return !hasImmediateInvoice; } if (coupon.duration === "repeating") { 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/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/computeAttachTaxPreview.ts b/server/src/internal/billing/v2/utils/billingPlan/preview/tax/computeAttachTaxPreview.ts index 46e66b10c..0b0ca52f4 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/preview/tax/computeAttachTaxPreview.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/preview/tax/computeAttachTaxPreview.ts @@ -59,10 +59,6 @@ export const computeAttachTaxPreview = async ({ billingContext: BillingContext; autumnBillingPlan: AutumnBillingPlan; }): Promise => { - if (!ctx.org.config.automatic_tax) return undefined; - if (billingContext.checkoutMode === "stripe_checkout") return undefined; - if (!billingContext.stripeCustomer?.id) return undefined; - const allLineItems = autumnBillingPlan.lineItems ?? []; if (allLineItems.length === 0) return undefined; @@ -78,6 +74,27 @@ export const computeAttachTaxPreview = async ({ 0, ); + return computeStripeTaxPreviewForNetSubtotal({ + ctx, + billingContext, + netSubtotal, + }); +}; + +/** Core Stripe Tax lookup for a net post-discount subtotal. */ +export const computeStripeTaxPreviewForNetSubtotal = async ({ + ctx, + billingContext, + netSubtotal, +}: { + ctx: AutumnContext; + billingContext: BillingContext; + netSubtotal: number; +}): Promise => { + if (!ctx.org.config.automatic_tax) return undefined; + if (billingContext.checkoutMode === "stripe_checkout") return undefined; + if (!billingContext.stripeCustomer?.id) return undefined; + const currency = orgToCurrency({ org: ctx.org }); const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); 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..66bf88b94 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,37 @@ 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 }), + ); + + return computeTaxRateIdPreviewFromTaxableMinorUnits({ + ctx, + billingContext, + taxableMinorUnits, + }); +}; + +/** Core tax-rate-id math over pre-computed taxable amounts (minor units). */ +export const computeTaxRateIdPreviewFromTaxableMinorUnits = ({ + ctx, + billingContext, + taxableMinorUnits, +}: { + ctx: AutumnContext; + billingContext: BillingContext; + taxableMinorUnits: number[]; +}): PreviewTax | undefined => { + if (!billingContext.taxRateId) return undefined; + + const currency = orgToCurrency({ org: ctx.org }); + 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 +137,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/preview/tax/computeNextCycleTaxPreview.ts b/server/src/internal/billing/v2/utils/billingPlan/preview/tax/computeNextCycleTaxPreview.ts new file mode 100644 index 000000000..d7c9e7c17 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingPlan/preview/tax/computeNextCycleTaxPreview.ts @@ -0,0 +1,36 @@ +import type { BillingContext, PreviewTax } from "@autumn/shared"; +import { atmnToStripeAmount, orgToCurrency } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { computeStripeTaxPreviewForNetSubtotal } from "./computeAttachTaxPreview"; +import { computeTaxRateIdPreviewFromTaxableMinorUnits } from "./computeAttachTaxRateIdPreview"; + +/** + * Tax preview for the next cycle, computed on its net post-discount total. + * Same precedence as the immediate preview: tax_rate_id overrides Stripe Tax. + */ +export const computeNextCycleTaxPreview = async ({ + ctx, + billingContext, + netSubtotal, +}: { + ctx: AutumnContext; + billingContext: BillingContext; + netSubtotal: number; +}): Promise => { + if (billingContext.taxRateId) { + const currency = orgToCurrency({ org: ctx.org }); + return computeTaxRateIdPreviewFromTaxableMinorUnits({ + ctx, + billingContext, + taxableMinorUnits: [ + atmnToStripeAmount({ amount: netSubtotal, currency }), + ], + }); + } + + return computeStripeTaxPreviewForNetSubtotal({ + ctx, + billingContext, + netSubtotal, + }); +}; 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 1ab8c3883..eb59a62f7 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCycleLineItems.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCycleLineItems.ts @@ -129,11 +129,17 @@ export const billingPlanToNextCycleLineItems = ({ ); if (billingContext.stripeDiscounts?.length) { + // Mirrors buildStripeInvoiceAction's condition for creating an invoice now. + const hasImmediateInvoice = (autumnBillingPlan.lineItems ?? []).some( + (lineItem) => lineItem.chargeImmediately && lineItem.amount !== 0, + ); + const nextCycleDiscounts = filterStripeDiscountsForNextCycle({ stripeDiscounts: billingContext.stripeDiscounts, currentEpochMs: billingContext.currentEpochMs, nextCycleStart, discountStartMs: billingContext.subscriptionBackdateStartMs, + hasImmediateInvoice, }); nextCycleAutumnLineItems = applyStripeDiscountsToLineItems({ diff --git a/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts b/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts index 0a23a7a0a..a340b9fb0 100644 --- a/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts +++ b/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts @@ -2,6 +2,7 @@ import type { BillingContext, BillingPlan } from "@autumn/shared"; import { type BillingPreviewResponse, orgToCurrency } from "@autumn/shared"; import { Decimal } from "decimal.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { computeNextCycleTaxPreview } from "./billingPlan/preview/tax/computeNextCycleTaxPreview"; import { billingPlanToImmediatePreview } from "./billingPlan/toImmediatePreview/billingPlanToImmediatePreview"; import { billingPlanToNextCyclePreview } from "./billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview"; import { billingPlanToChanges } from "./billingPlan/toPreviewChanges/billingPlanToChanges"; @@ -16,13 +17,40 @@ import { logBillingPreview } from "./logs/logBillingPreview"; * never goes negative. Leftover credit rolls to the next invoice in * Stripe; we don't surface that here beyond the row tooltip on the FE. * - * `next_cycle.total` is intentionally NOT adjusted — we don't compute - * next-cycle tax (would require a forward-dated Stripe Tax calculation), - * and the `subtotal`/`total` doc strings on `next_cycle` reflect that. + * `next_cycle.total` is adjusted separately via `applyNextCycleTaxPreview` + * (tax only — credits are consumed by the immediate invoice first). * * If `billingPlan.preview` is undefined (non-attach flows that skip the * enrichment step) the math is a no-op and `total` is unchanged. */ +// Tax-only next-cycle adjustment; skipped for non-preview flows (no +// billingPlan.preview bag) so checkout recomputes stay unchanged. +const applyNextCycleTaxPreview = async ({ + ctx, + billingContext, + billingPlan, + nextCycle, +}: { + ctx: AutumnContext; + billingContext: BillingContext; + billingPlan: BillingPlan; + nextCycle: BillingPreviewResponse["next_cycle"]; +}): Promise => { + if (!nextCycle || !billingPlan.preview) return nextCycle; + + const tax = await computeNextCycleTaxPreview({ + ctx, + billingContext, + netSubtotal: nextCycle.total, + }); + if (!tax) return nextCycle; + + return { + ...nextCycle, + total: new Decimal(nextCycle.total).add(tax.total).toDP(2).toNumber(), + }; +}; + const applyPreviewAdjustmentsToTotal = ({ subtotal, total, @@ -70,10 +98,18 @@ export const billingPlanToPreviewResponse = async ({ const currency = orgToCurrency({ org: ctx.org }); // Get next cycle object - const { nextCycle, debug: nextCycleDebug } = billingPlanToNextCyclePreview({ + const { nextCycle: rawNextCycle, debug: nextCycleDebug } = + billingPlanToNextCyclePreview({ + ctx, + billingContext, + billingPlan, + }); + + const nextCycle = await applyNextCycleTaxPreview({ ctx, billingContext, billingPlan, + nextCycle: rawNextCycle, }); logBillingPreview({ 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/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 index bfcbcd21d..f5dfc2168 100644 --- a/server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts +++ b/server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts @@ -2,6 +2,7 @@ import { generateKsuid } from "@autumn/ksuid"; import type { BillingContext } from "@autumn/shared"; import { customerProductToEntity, + cusPriceToCusEnt, type DbInvoiceLineItem, type FullCusProduct, type InvoiceLineItemDiscount, @@ -15,12 +16,14 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; export const chargeRowToRefundLineItem = ({ chargeRow, creditAmount, + effectiveNow, customerProduct, billingContext, ctx, }: { chargeRow: DbInvoiceLineItem; creditAmount: number; + effectiveNow: number; customerProduct: FullCusProduct; billingContext: BillingContext; ctx: AutumnContext; @@ -50,6 +53,12 @@ export const chargeRowToRefundLineItem = ({ ); const price = matchingCusPrice?.price ?? customerProduct.customer_prices[0]?.price; + const matchingCusEnt = matchingCusPrice + ? cusPriceToCusEnt({ + cusPrice: matchingCusPrice, + cusEnts: customerProduct.customer_entitlements, + }) + : undefined; if (!price) { throw new Error( @@ -60,17 +69,18 @@ export const chargeRowToRefundLineItem = ({ const context: LineItemContext = { price, product: customerProduct.product, - feature: undefined, + feature: matchingCusEnt?.entitlement.feature, currency: orgToCurrency({ org: ctx.org }), billingPeriod: { start: periodStart, end: periodEnd }, - effectivePeriod: { start: billingContext.currentEpochMs, end: periodEnd }, + effectivePeriod: { start: effectiveNow, end: periodEnd }, direction: "refund", - now: billingContext.currentEpochMs, + now: effectiveNow, billingTiming: "in_advance", discountable: false, entity, customerProduct, customerPrice: matchingCusPrice, + customerEntitlement: matchingCusEnt, }; const description = chargeRow.description diff --git a/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts index 423284811..91d1bc089 100644 --- a/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts +++ b/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts @@ -5,6 +5,7 @@ import { type LineItem, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { augmentBillingContextForAnchorResetRefund } from "./augmentBillingContextForAnchorResetRefund"; import { chargeRowToRefundLineItem } from "./chargeRowToRefundLineItem"; import { computeAlreadyRefundedForCharge, @@ -81,6 +82,20 @@ export const invoiceCreditFromStoredLineItems = ({ ); for (const chargeRow of usableRows) { + const periodStart = chargeRow.effective_period_start; + const periodEnd = chargeRow.effective_period_end; + if (periodStart == null || periodEnd == null) continue; + + const action = augmentBillingContextForAnchorResetRefund({ + currentEpochMs: now, + billingPeriod: { start: periodStart, end: periodEnd }, + anchorResetRefund: billingContext.anchorResetRefund, + }); + + if (action.type === "skip") continue; + const effectiveNow = + action.type === "use_snapped_now" ? action.snappedNow : now; + const attributedAmount = splitMultiEntityAmount(chargeRow); const alreadyRefunded = computeAlreadyRefundedForCharge({ @@ -95,7 +110,7 @@ export const invoiceCreditFromStoredLineItems = ({ const creditAmount = computeProratedCredit({ chargeRow: adjustedChargeRow, - now, + now: effectiveNow, alreadyRefunded, }); @@ -105,6 +120,7 @@ export const invoiceCreditFromStoredLineItems = ({ chargeRowToRefundLineItem({ chargeRow, creditAmount, + effectiveNow, customerProduct, billingContext, 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/getApiCustomerByRollout.ts b/server/src/internal/customers/actions/getApiCustomerByRollout.ts index 2e097f824..9f33dfcf8 100644 --- a/server/src/internal/customers/actions/getApiCustomerByRollout.ts +++ b/server/src/internal/customers/actions/getApiCustomerByRollout.ts @@ -1,3 +1,4 @@ +import { shed503OnTransientError } from "@/db/shed503OnTransientError.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { getOrSetCachedFullSubject } from "@/internal/customers/cache/fullSubject/index.js"; import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js"; @@ -19,11 +20,11 @@ export const getApiCustomerByRollout = async ({ withAutumnId?: boolean; }) => { if (isFullSubjectRolloutEnabled({ ctx })) { - const fullSubject = await getOrSetCachedFullSubject({ + const fullSubject = await shed503OnTransientError({ ctx, - customerId, - entityId, - source, + source: "get_customer", + run: () => + getOrSetCachedFullSubject({ ctx, customerId, entityId, source }), }); return getApiCustomerV2({ diff --git a/server/src/internal/customers/actions/getOrCreateApiCustomerByRollout.ts b/server/src/internal/customers/actions/getOrCreateApiCustomerByRollout.ts index 91036172e..a6021a357 100644 --- a/server/src/internal/customers/actions/getOrCreateApiCustomerByRollout.ts +++ b/server/src/internal/customers/actions/getOrCreateApiCustomerByRollout.ts @@ -1,4 +1,5 @@ import type { CheckParams, TrackParams } from "@autumn/shared"; +import { shed503OnTransientError } from "@/db/shed503OnTransientError.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { getOrCreateCachedFullSubject } from "@/internal/customers/cache/fullSubject/index.js"; import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js"; @@ -28,10 +29,10 @@ export const getOrCreateApiCustomerByRollout = async ({ | undefined; if (isFullSubjectRolloutEnabled({ ctx })) { - fullSubject = await getOrCreateCachedFullSubject({ + fullSubject = await shed503OnTransientError({ ctx, - params, - source, + source: "get_or_create", + run: () => getOrCreateCachedFullSubject({ ctx, params, source }), }); } else { fullCustomer = await getOrCreateCachedFullCustomer({ 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/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/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.ts index e099d1cbb..eda3840fb 100644 --- a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.ts +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.ts @@ -40,6 +40,13 @@ export const mergeAggregatedBalanceIntoApiBalanceV2 = ({ const aggregatedRolloverGrant = new Decimal(aggregatedRolloverBalance) .add(aggregatedRolloverUsage) .toNumber(); + const aggregatedNextResetAt = aggregatedFeatureBalance.next_reset_at ?? null; + const nextResetAt = + apiBalance.next_reset_at === null + ? aggregatedNextResetAt + : aggregatedNextResetAt === null + ? apiBalance.next_reset_at + : Math.min(apiBalance.next_reset_at, aggregatedNextResetAt); // 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. @@ -77,6 +84,7 @@ export const mergeAggregatedBalanceIntoApiBalanceV2 = ({ apiBalance.overage_allowed || aggregatedFeatureBalance.usage_allowed || false, + next_reset_at: nextResetAt, breakdown: apiBalance.breakdown ?? [], }; }; 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/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/repos/getFullSubject/getEntityAggregateFragments.ts b/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments.ts index 2890f51fc..f69696150 100644 --- a/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments.ts +++ b/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments.ts @@ -201,6 +201,7 @@ export const getEntityAggregateFragments = ({ SUM(ce.balance::numeric) AS balance, SUM(COALESCE(ce.adjustment, 0)::numeric) AS adjustment, SUM(COALESCE(ce.additional_balance, 0)::numeric) AS additional_balance, + MIN(ce.next_reset_at) AS next_reset_at, BOOL_OR(ce.unlimited) AS unlimited, BOOL_OR(ce.usage_allowed) AS usage_allowed FROM entity_level_cus_ents ce @@ -219,6 +220,7 @@ export const getEntityAggregateFragments = ({ eat.balance, eat.adjustment, eat.additional_balance, + eat.next_reset_at, COALESCE(erf.rollover_balance, 0) AS rollover_balance, COALESCE(erf.rollover_usage, 0) AS rollover_usage, eat.unlimited, diff --git a/server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts b/server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts index 5e876fecc..698fd967a 100644 --- a/server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts +++ b/server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts @@ -2,8 +2,39 @@ import { type CusProductStatus, RELEVANT_STATUSES } from "@autumn/shared"; import { type SQL, sql } from "drizzle-orm"; import { getEntityAggregateFragments } from "./getEntityAggregateFragments.js"; -const CUSTOMER_PRODUCT_LIMIT = 200; -const EXTRA_CUSTOMER_ENTITLEMENT_LIMIT = 200; +export const CUSTOMER_PRODUCT_LIMIT = 200; +export const EXTRA_CUSTOMER_ENTITLEMENT_LIMIT = 200; + +/** Aggregate CTE → SubjectQueryRow column. Each CTE must expose (subject_key, items). */ +const SUBJECT_AGGREGATES = [ + { cte: "cus_products_agg", column: "customer_products" }, + { cte: "cus_entitlements_agg", column: "customer_entitlements" }, + { cte: "cus_prices_agg", column: "customer_prices" }, + { cte: "extra_cus_entitlements_agg", column: "extra_customer_entitlements" }, + { cte: "replaceables_agg", column: "replaceables" }, + { cte: "rollovers_agg", column: "rollovers" }, + { cte: "products_agg", column: "products" }, + { cte: "entitlements_agg", column: "entitlements" }, + { cte: "prices_agg", column: "prices" }, + { cte: "free_trials_agg", column: "free_trials" }, + { cte: "subscriptions_agg", column: "subscriptions" }, +] as const; + +const aggregateSelects = sql.join( + SUBJECT_AGGREGATES.map(({ cte, column }) => + sql.raw(`COALESCE(${cte}.items, '[]'::json) AS ${column}`), + ), + sql`, + `, +); + +const aggregateJoins = sql.join( + SUBJECT_AGGREGATES.map(({ cte }) => + sql.raw(`LEFT JOIN ${cte} ON ${cte}.subject_key = sr.subject_key`), + ), + sql` + `, +); const emptyEntityFragments = { ctes: sql``, @@ -19,11 +50,14 @@ export const getFullSubjectRowsQuery = ({ inStatuses, includeInvoices, includeEntityAggregations, + entityScopedOnly = false, }: { leadingCtes: SQL; inStatuses: CusProductStatus[]; includeInvoices: boolean; includeEntityAggregations: boolean; + /** Only hydrate rows scoped to the subject's entity (requires non-null internal_entity_id on every subject). Customer-level rows must be merged back in separately. */ + entityScopedOnly?: boolean; }) => { const statusFilter = inStatuses.length > 0 @@ -50,6 +84,29 @@ export const getFullSubjectRowsQuery = ({ }) : emptyEntityFragments; + const customerProductSubjectPredicate = entityScopedOnly + ? sql`cp.internal_entity_id = sr.internal_entity_id` + : sql`cp.internal_customer_id = sr.internal_customer_id + AND ( + (sr.internal_entity_id IS NULL AND cp.internal_entity_id IS NULL) + OR + (sr.internal_entity_id IS NOT NULL AND ( + cp.internal_entity_id IS NULL + OR cp.internal_entity_id = sr.internal_entity_id + )) + )`; + + const customerEntitlementSubjectPredicate = entityScopedOnly + ? sql`AND ce.internal_entity_id = sr.internal_entity_id` + : sql`AND ( + (sr.internal_entity_id IS NULL AND ce.internal_entity_id IS NULL) + OR + (sr.internal_entity_id IS NOT NULL AND ( + ce.internal_entity_id IS NULL + OR ce.internal_entity_id = sr.internal_entity_id + )) + )`; + const invoicesCte = includeInvoices ? sql`, @@ -83,7 +140,7 @@ export const getFullSubjectRowsQuery = ({ ${leadingCtes} , - subject_customer_records AS ( + subject_customer_records AS MATERIALIZED ( SELECT DISTINCT c.* FROM customers c JOIN subject_records sr @@ -119,15 +176,7 @@ export const getFullSubjectRowsQuery = ({ FROM customer_products cp JOIN products prod ON prod.internal_id = cp.internal_product_id - WHERE cp.internal_customer_id = sr.internal_customer_id - AND ( - (sr.internal_entity_id IS NULL AND cp.internal_entity_id IS NULL) - OR - (sr.internal_entity_id IS NOT NULL AND ( - cp.internal_entity_id IS NULL - OR cp.internal_entity_id = sr.internal_entity_id - )) - ) + WHERE ${customerProductSubjectPredicate} ${statusFilter} ) cp_candidates ON true ), @@ -175,14 +224,7 @@ export const getFullSubjectRowsQuery = ({ AND f.type = 'boolean' ) ) - AND ( - (sr.internal_entity_id IS NULL AND ce.internal_entity_id IS NULL) - OR - (sr.internal_entity_id IS NOT NULL AND ( - ce.internal_entity_id IS NULL - OR ce.internal_entity_id = sr.internal_entity_id - )) - ) + ${customerEntitlementSubjectPredicate} ORDER BY subject_entity_priority ASC, ce.id DESC LIMIT ${EXTRA_CUSTOMER_ENTITLEMENT_LIMIT} ) ce_ordered ON true @@ -300,149 +342,147 @@ export const getFullSubjectRowsQuery = ({ ${entityFragments.freeTrialRefsUnion} ) src ON ft.id = src.free_trial_id ORDER BY src.subject_key, ft.id + ), + + cus_products_agg AS ( + SELECT + cp.subject_key, + json_agg( + ( + row_to_json(cp)::jsonb + - 'subject_key' + - 'subject_entity_priority' + - 'status_priority' + - 'has_customer_prices' + - 'product_is_add_on' + - 'subject_rank' + )::json + ORDER BY + cp.subject_entity_priority ASC, + cp.status_priority ASC, + cp.has_customer_prices DESC, + cp.product_is_add_on ASC, + cp.created_at DESC + ) AS items + FROM cus_products cp + GROUP BY cp.subject_key + ), + + cus_entitlements_agg AS ( + SELECT + ce.subject_key, + json_agg((row_to_json(ce)::jsonb - 'subject_key')::json) AS items + FROM cus_entitlements ce + GROUP BY ce.subject_key + ), + + cus_prices_agg AS ( + SELECT + cpr.subject_key, + json_agg((row_to_json(cpr)::jsonb - 'subject_key')::json) AS items + FROM cus_prices cpr + GROUP BY cpr.subject_key + ), + + extra_cus_entitlements_agg AS ( + SELECT + ece.subject_key, + json_agg( + ( + row_to_json(ece)::jsonb + - 'subject_key' + - 'subject_entity_priority' + )::json + ORDER BY ece.subject_entity_priority ASC, ece.id DESC + ) AS items + FROM extra_cus_entitlements ece + GROUP BY ece.subject_key + ), + + replaceables_agg AS ( + SELECT + ace.subject_key, + json_agg(row_to_json(rep) ORDER BY rep.created_at ASC, rep.id ASC) AS items + FROM cus_replaceables rep + JOIN all_cus_ent_ids ace + ON ace.id = rep.cus_ent_id + GROUP BY ace.subject_key + ), + + rollovers_agg AS ( + SELECT + ace.subject_key, + json_agg( + row_to_json(ro) + ORDER BY ro.expires_at ASC NULLS LAST, ro.id ASC + ) AS items + FROM cus_rollovers ro + JOIN all_cus_ent_ids ace + ON ace.id = ro.cus_ent_id + GROUP BY ace.subject_key + ), + + products_agg AS ( + SELECT + p.subject_key, + json_agg( + (row_to_json(p)::jsonb - 'internal_customer_id' - 'subject_key')::json + ORDER BY p.internal_id + ) AS items + FROM distinct_products p + GROUP BY p.subject_key + ), + + entitlements_agg AS ( + SELECT + ent.subject_key, + json_agg((row_to_json(ent)::jsonb - 'internal_customer_id' - 'subject_key')::json) AS items + FROM distinct_entitlements ent + GROUP BY ent.subject_key + ), + + prices_agg AS ( + SELECT + pr.subject_key, + json_agg( + (row_to_json(pr)::jsonb - 'internal_customer_id' - 'subject_key')::json + ORDER BY pr.id + ) AS items + FROM distinct_prices pr + GROUP BY pr.subject_key + ), + + free_trials_agg AS ( + SELECT + ft.subject_key, + json_agg( + (row_to_json(ft)::jsonb - 'internal_customer_id' - 'subject_key')::json + ORDER BY ft.id + ) AS items + FROM distinct_free_trials ft + GROUP BY ft.subject_key + ), + + subscriptions_agg AS ( + SELECT + cs.subject_key, + json_agg(row_to_json(cs.subscription_row)) + FILTER (WHERE (cs.subscription_row).stripe_id IS NOT NULL) AS items + FROM ( + SELECT DISTINCT + cp.subject_key, + s AS subscription_row + FROM cus_products cp + JOIN LATERAL unnest(cp.subscription_ids) AS cp_sub(stripe_id) ON true + JOIN subscriptions s + ON s.stripe_id = cp_sub.stripe_id + ) cs + GROUP BY cs.subject_key ) SELECT row_to_json(scr) AS customer, - - COALESCE( - ( - SELECT json_agg( - ( - row_to_json(cp)::jsonb - - 'subject_key' - - 'subject_entity_priority' - - 'status_priority' - - 'has_customer_prices' - - 'product_is_add_on' - - 'subject_rank' - )::json - ORDER BY - cp.subject_entity_priority ASC, - cp.status_priority ASC, - cp.has_customer_prices DESC, - cp.product_is_add_on ASC, - cp.created_at DESC - ) - FROM cus_products cp - WHERE cp.subject_key = sr.subject_key - ), - '[]'::json - ) AS customer_products, - - COALESCE( - ( - SELECT json_agg((row_to_json(ce)::jsonb - 'subject_key')::json) - FROM cus_entitlements ce - WHERE ce.subject_key = sr.subject_key - ), - '[]'::json - ) AS customer_entitlements, - - COALESCE( - ( - SELECT json_agg((row_to_json(cpr)::jsonb - 'subject_key')::json) - FROM cus_prices cpr - WHERE cpr.subject_key = sr.subject_key - ), - '[]'::json - ) AS customer_prices, - - COALESCE( - ( - SELECT json_agg( - ( - row_to_json(ece)::jsonb - - 'subject_key' - - 'subject_entity_priority' - )::json - ORDER BY ece.subject_entity_priority ASC, ece.id DESC - ) - FROM extra_cus_entitlements ece - WHERE ece.subject_key = sr.subject_key - ), - '[]'::json - ) AS extra_customer_entitlements, - - COALESCE( - ( - SELECT json_agg(row_to_json(rep) ORDER BY rep.created_at ASC, rep.id ASC) - FROM cus_replaceables rep - WHERE rep.cus_ent_id IN ( - SELECT ace.id - FROM all_cus_ent_ids ace - WHERE ace.subject_key = sr.subject_key - ) - ), - '[]'::json - ) AS replaceables, - - COALESCE( - ( - SELECT json_agg( - row_to_json(ro) - ORDER BY ro.expires_at ASC NULLS LAST, ro.id ASC - ) - FROM cus_rollovers ro - WHERE ro.cus_ent_id IN ( - SELECT ace.id - FROM all_cus_ent_ids ace - WHERE ace.subject_key = sr.subject_key - ) - ), - '[]'::json - ) AS rollovers, - - COALESCE( - ( - SELECT json_agg((row_to_json(p)::jsonb - 'internal_customer_id' - 'subject_key')::json) - FROM distinct_products p - WHERE p.subject_key = sr.subject_key - ), - '[]'::json - ) AS products, - - COALESCE( - ( - SELECT json_agg((row_to_json(ent)::jsonb - 'internal_customer_id' - 'subject_key')::json) - FROM distinct_entitlements ent - WHERE ent.subject_key = sr.subject_key - ), - '[]'::json - ) AS entitlements, - - COALESCE( - ( - SELECT json_agg((row_to_json(pr)::jsonb - 'internal_customer_id' - 'subject_key')::json) - FROM distinct_prices pr - WHERE pr.subject_key = sr.subject_key - ), - '[]'::json - ) AS prices, - - COALESCE( - ( - SELECT json_agg((row_to_json(ft)::jsonb - 'internal_customer_id' - 'subject_key')::json) - FROM distinct_free_trials ft - WHERE ft.subject_key = sr.subject_key - ), - '[]'::json - ) AS free_trials, - - COALESCE( - ( - SELECT json_agg(row_to_json(cs)) FILTER (WHERE cs.stripe_id IS NOT NULL) - FROM ( - SELECT DISTINCT s.* - FROM cus_products cp - JOIN LATERAL unnest(cp.subscription_ids) AS cp_sub(stripe_id) ON true - JOIN subscriptions s - ON s.stripe_id = cp_sub.stripe_id - WHERE cp.subject_key = sr.subject_key - ) cs - ), - '[]'::json - ) AS subscriptions + ${aggregateSelects} ${invoicesSelect}, @@ -455,6 +495,7 @@ export const getFullSubjectRowsQuery = ({ FROM subject_records sr JOIN subject_customer_records scr ON scr.internal_id = sr.internal_customer_id + ${aggregateJoins} LEFT JOIN entities er ON er.internal_id = sr.internal_entity_id ORDER BY sr.subject_order diff --git a/server/src/internal/customers/repos/getFullSubject/mergeEntityAndCustomerSubjectRows.ts b/server/src/internal/customers/repos/getFullSubject/mergeEntityAndCustomerSubjectRows.ts new file mode 100644 index 000000000..73ad4c968 --- /dev/null +++ b/server/src/internal/customers/repos/getFullSubject/mergeEntityAndCustomerSubjectRows.ts @@ -0,0 +1,136 @@ +import type { SubjectQueryRow } from "@autumn/shared"; +import { + CUSTOMER_PRODUCT_LIMIT, + EXTRA_CUSTOMER_ENTITLEMENT_LIMIT, +} from "./getFullSubjectRowsQuery.js"; + +const dedupeBy = (rows: T[], getKey: (row: T) => string): T[] => { + const seen = new Map(); + for (const row of rows) { + if (!seen.has(getKey(row))) seen.set(getKey(row), row); + } + return [...seen.values()]; +}; + +/** Dedupe, keep only referenced rows, and sort by key to mirror the SQL's DISTINCT ON ... ORDER BY output. */ +const mergeCatalog = ( + rows: T[], + getKey: (row: T) => string, + keptKeys: Set, +): T[] => + dedupeBy(rows, getKey) + .filter((row) => keptKeys.has(getKey(row))) + .sort((left, right) => (getKey(left) < getKey(right) ? -1 : 1)); + +/** + * Recombines an entityScopedOnly subject row with its customer's + * customer-level row into the SubjectQueryRow the combined query would + * produce. Entity rows concat before customer rows (subject_entity_priority + * leads the SQL ranking), the caps apply to the combined arrays, and since + * the SQL derives every other array AFTER the caps, rows referencing + * capped-out customer products are dropped here too. + */ +export const mergeEntityAndCustomerSubjectRows = ({ + entityRow, + customerRow, +}: { + entityRow: SubjectQueryRow; + customerRow: SubjectQueryRow | undefined; +}): SubjectQueryRow => { + if (!customerRow) return entityRow; + + // The entityScopedOnly query matches on internal_entity_id alone (adding the + // customer predicate degrades its plan), so enforce the customer match here. + // Dependent rows of any dropped product are filtered transitively below. + const customerProducts = [ + ...entityRow.customer_products.filter( + (product) => + product.internal_customer_id === entityRow.customer.internal_id, + ), + ...customerRow.customer_products, + ].slice(0, CUSTOMER_PRODUCT_LIMIT); + + const extraCustomerEntitlements = [ + ...entityRow.extra_customer_entitlements, + ...customerRow.extra_customer_entitlements, + ].slice(0, EXTRA_CUSTOMER_ENTITLEMENT_LIMIT); + + const keptProductIds = new Set( + customerProducts.map((product) => product.id), + ); + + const customerEntitlements = [ + ...entityRow.customer_entitlements, + ...customerRow.customer_entitlements, + ].filter((entitlement) => keptProductIds.has(entitlement.customer_product_id)); + + const customerPrices = [ + ...entityRow.customer_prices, + ...customerRow.customer_prices, + ].filter((price) => keptProductIds.has(price.customer_product_id)); + + const keptCusEntIds = new Set( + [...customerEntitlements, ...extraCustomerEntitlements].map((ce) => ce.id), + ); + const keptRefs = { + products: new Set( + customerProducts.map((p) => p.internal_product_id), + ), + prices: new Set(customerPrices.map((p) => p.price_id)), + entitlements: new Set( + [...customerEntitlements, ...extraCustomerEntitlements].map( + (ce) => ce.entitlement_id, + ), + ), + freeTrials: new Set( + customerProducts.map((p) => p.free_trial_id), + ), + subscriptionIds: new Set( + customerProducts.flatMap((p) => p.subscription_ids ?? []), + ), + }; + + // Explicit keys only (no spreads): a new required SubjectQueryRow field must + // fail compilation here until this merge handles it. + return { + customer: entityRow.customer, + entity: entityRow.entity, + customer_products: customerProducts, + customer_entitlements: customerEntitlements, + customer_prices: customerPrices, + extra_customer_entitlements: extraCustomerEntitlements, + rollovers: [...entityRow.rollovers, ...customerRow.rollovers].filter( + (rollover) => keptCusEntIds.has(rollover.cus_ent_id), + ), + replaceables: [ + ...entityRow.replaceables, + ...customerRow.replaceables, + ].filter((replaceable) => keptCusEntIds.has(replaceable.cus_ent_id)), + products: mergeCatalog( + [...entityRow.products, ...customerRow.products], + (p) => p.internal_id, + keptRefs.products, + ), + entitlements: dedupeBy( + [...entityRow.entitlements, ...customerRow.entitlements], + (e) => e.id, + ).filter((e) => keptRefs.entitlements.has(e.id)), + prices: mergeCatalog( + [...entityRow.prices, ...customerRow.prices], + (p) => p.id, + keptRefs.prices, + ), + free_trials: mergeCatalog( + [...entityRow.free_trials, ...customerRow.free_trials], + (ft) => ft.id, + keptRefs.freeTrials, + ), + subscriptions: dedupeBy( + [...entityRow.subscriptions, ...customerRow.subscriptions], + (s) => s.stripe_id ?? "", + ).filter( + (s) => + s.stripe_id !== null && keptRefs.subscriptionIds.has(s.stripe_id), + ), + }; +}; 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/entities/actions/getApiEntityByRollout.ts b/server/src/internal/entities/actions/getApiEntityByRollout.ts index 656d3db4c..e87b88b5f 100644 --- a/server/src/internal/entities/actions/getApiEntityByRollout.ts +++ b/server/src/internal/entities/actions/getApiEntityByRollout.ts @@ -1,4 +1,5 @@ import type { ApiEntityV2 } from "@autumn/shared"; +import { shed503OnTransientError } from "@/db/shed503OnTransientError.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { getOrSetCachedFullSubject } from "@/internal/customers/cache/fullSubject/index.js"; import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js"; @@ -19,15 +20,13 @@ export const getApiEntityByRollout = async ({ withAutumnId?: boolean; }): Promise => { if (isFullSubjectRolloutEnabled({ ctx })) { - const fullSubject = await getOrSetCachedFullSubject({ + const fullSubject = await shed503OnTransientError({ ctx, - customerId, - entityId, - source, + source: "entities.get", + run: () => + getOrSetCachedFullSubject({ ctx, customerId, entityId, source }), }); - - return getApiEntityV2({ ctx, fullSubject, diff --git a/server/src/internal/entities/handlers/handleGetEntity/handleGetEntityV2.ts b/server/src/internal/entities/handlers/handleGetEntity/handleGetEntityV2.ts index 01de6adba..597aaf806 100644 --- a/server/src/internal/entities/handlers/handleGetEntity/handleGetEntityV2.ts +++ b/server/src/internal/entities/handlers/handleGetEntity/handleGetEntityV2.ts @@ -4,6 +4,7 @@ import { InternalError, Scopes, } from "@autumn/shared"; +import { shed503OnTransientError } from "@/db/shed503OnTransientError.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { findCustomerForEntity } from "../../actions/findCustomer.js"; import { getApiEntityByRollout } from "../../actions/getApiEntityByRollout.js"; @@ -19,9 +20,10 @@ export const handleGetEntityV2 = createRoute({ // 1. Entity -> Customer ID if (!customerId) { - const customer = await findCustomerForEntity({ + const customer = await shed503OnTransientError({ ctx, - entityId: entityId, + source: "entities.get.find_customer", + run: () => findCustomerForEntity({ ctx, entityId }), }); if (!customer?.id) { diff --git a/server/src/internal/entities/handlers/handleListEntitiesV2.ts b/server/src/internal/entities/handlers/handleListEntitiesV2.ts index efc1ac726..371a150b2 100644 --- a/server/src/internal/entities/handlers/handleListEntitiesV2.ts +++ b/server/src/internal/entities/handlers/handleListEntitiesV2.ts @@ -30,6 +30,7 @@ import { resultToFullSubject } from "@/internal/customers/repos/getFullSubject/i import { getOrgPaginationMaxLimit } from "../../misc/edgeConfig/orgLimitsStore.js"; import { getApiEntityBaseV2 } from "../entityUtils/getApiEntityV2/getApiEntityBaseV2.js"; import { getCursorPaginatedEntitySubjectsQuery } from "../repos/cursorListEntitiesQuery.js"; +import { hydrateEntityRowsWithCustomerData } from "../repos/hydrateEntityRowsWithCustomerData.js"; import { countEntitiesByOrgIdAndEnv, countFilteredEntitiesByOrgIdAndEnv, @@ -50,13 +51,21 @@ const getListEntitiesStatuses = ({ const buildApiEntitiesFromRows = async ({ ctx, rows, + inStatuses, }: { ctx: RequestContext; rows: unknown[]; + inStatuses: CusProductStatus[]; }) => { - const fullSubjects = rows.map((row) => + const mergedRows = await hydrateEntityRowsWithCustomerData({ + ctx, + entityRows: rows as unknown as SubjectQueryRow[], + inStatuses, + }); + + const fullSubjects = mergedRows.map((row) => resultToFullSubject({ - row: row as unknown as SubjectQueryRow, + row, entityIdRequested: true, }), ); @@ -141,7 +150,11 @@ const runOffsetListEntities = async ({ }) : totalCount; - const entities = await buildApiEntitiesFromRows({ ctx, rows: subjectRows }); + const entities = await buildApiEntitiesFromRows({ + ctx, + rows: subjectRows, + inStatuses, + }); const hasMore = body.offset + entities.length < totalFilteredCount; @@ -207,7 +220,11 @@ export const handleListEntitiesV2 = createRoute({ const hasMore = rows.length > body.limit; const pageRows = hasMore ? rows.slice(0, body.limit) : rows; - const entities = await buildApiEntitiesFromRows({ ctx, rows: pageRows }); + const entities = await buildApiEntitiesFromRows({ + ctx, + rows: pageRows, + inStatuses, + }); const lastRow = pageRows[pageRows.length - 1] as | { entity?: { id?: string; created_at?: number | string } } diff --git a/server/src/internal/entities/repos/cursorListEntitiesQuery.ts b/server/src/internal/entities/repos/cursorListEntitiesQuery.ts index 86d595324..52b33dc7d 100644 --- a/server/src/internal/entities/repos/cursorListEntitiesQuery.ts +++ b/server/src/internal/entities/repos/cursorListEntitiesQuery.ts @@ -151,5 +151,6 @@ export const getCursorPaginatedEntitySubjectsQuery = ({ inStatuses, includeInvoices: false, includeEntityAggregations: false, + entityScopedOnly: true, }); }; diff --git a/server/src/internal/entities/repos/customerLevelSubjectsQuery.ts b/server/src/internal/entities/repos/customerLevelSubjectsQuery.ts new file mode 100644 index 000000000..d73f0724b --- /dev/null +++ b/server/src/internal/entities/repos/customerLevelSubjectsQuery.ts @@ -0,0 +1,42 @@ +import type { AppEnv, CusProductStatus } from "@autumn/shared"; +import { sql } from "drizzle-orm"; +import { getFullSubjectRowsQuery } from "@/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.js"; + +/** Hydrates customer-level subject rows once per customer, merged back into entity list rows by mergeEntityAndCustomerSubjectRows. */ +export const getCustomerLevelSubjectRowsQuery = ({ + orgId, + env, + internalCustomerIds, + inStatuses, +}: { + orgId: string; + env: AppEnv; + internalCustomerIds: string[]; + inStatuses: CusProductStatus[]; +}) => { + const idList = sql.join( + internalCustomerIds.map((internalCustomerId) => sql`${internalCustomerId}`), + sql`, `, + ); + + const leadingCtes = sql` + WITH subject_records AS ( + SELECT + c.internal_id AS subject_key, + c.internal_id AS internal_customer_id, + NULL::text AS internal_entity_id, + ROW_NUMBER() OVER (ORDER BY c.internal_id) AS subject_order + FROM customers c + WHERE c.internal_id IN (${idList}) + AND c.org_id = ${orgId} + AND c.env = ${env} + ) + `; + + return getFullSubjectRowsQuery({ + leadingCtes, + inStatuses, + includeInvoices: false, + includeEntityAggregations: false, + }); +}; diff --git a/server/src/internal/entities/repos/hydrateEntityRowsWithCustomerData.ts b/server/src/internal/entities/repos/hydrateEntityRowsWithCustomerData.ts new file mode 100644 index 000000000..acae12a7c --- /dev/null +++ b/server/src/internal/entities/repos/hydrateEntityRowsWithCustomerData.ts @@ -0,0 +1,46 @@ +import type { CusProductStatus, SubjectQueryRow } from "@autumn/shared"; +import type { RequestContext } from "@/honoUtils/HonoEnv.js"; +import { mergeEntityAndCustomerSubjectRows } from "@/internal/customers/repos/getFullSubject/mergeEntityAndCustomerSubjectRows.js"; +import { getCustomerLevelSubjectRowsQuery } from "./customerLevelSubjectsQuery.js"; + +/** Fetches customer-level data once per distinct customer on the page and merges it into each entityScopedOnly row. */ +export const hydrateEntityRowsWithCustomerData = async ({ + ctx, + entityRows, + inStatuses, +}: { + ctx: RequestContext; + entityRows: SubjectQueryRow[]; + inStatuses: CusProductStatus[]; +}): Promise => { + if (entityRows.length === 0) return entityRows; + + const internalCustomerIds = [ + ...new Set(entityRows.map((row) => row.customer.internal_id)), + ]; + + const customerRows = (await ctx.db.execute( + getCustomerLevelSubjectRowsQuery({ + orgId: ctx.org.id, + env: ctx.env, + internalCustomerIds, + inStatuses, + }), + )) as unknown as SubjectQueryRow[]; + + const customerRowsByInternalId = new Map( + customerRows.map((row) => [row.customer.internal_id, row]), + ); + + return entityRows.map((entityRow) => { + const customerRow = customerRowsByInternalId.get( + entityRow.customer.internal_id, + ); + if (!customerRow) { + ctx.logger.warn( + `[hydrateEntityRowsWithCustomerData] missing customer-level row for internal customer id ${entityRow.customer.internal_id}`, + ); + } + return mergeEntityAndCustomerSubjectRows({ entityRow, customerRow }); + }); +}; diff --git a/server/src/internal/entities/repos/listEntitiesQuery.ts b/server/src/internal/entities/repos/listEntitiesQuery.ts index 8ea5fae40..fe49db123 100644 --- a/server/src/internal/entities/repos/listEntitiesQuery.ts +++ b/server/src/internal/entities/repos/listEntitiesQuery.ts @@ -174,6 +174,7 @@ export const getPaginatedEntitySubjectsQuery = ({ inStatuses, includeInvoices: false, includeEntityAggregations: false, + entityScopedOnly: true, }); }; diff --git a/server/src/internal/features/aiCreditSystemUtils.ts b/server/src/internal/features/aiCreditSystemUtils.ts new file mode 100644 index 000000000..b0eb44de2 --- /dev/null +++ b/server/src/internal/features/aiCreditSystemUtils.ts @@ -0,0 +1,265 @@ +import { + ErrCode, + type Feature, + isCustomModel, + type ModelsDevCost, + type ModelsDevCostTier, + type ModelsDevModel, + type ModelsDevProvider, + RecaseError, + splitModelId, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import { getModelsDevPricing } from "@/internal/features/utils/getModelPricing.js"; + +export type TokenInput = { + input: number; + output: number; + cacheRead?: number; + cacheWrite?: number; + audioInput?: number; + audioOutput?: number; + reasoning?: number; +}; + +type ModelPricingData = Record; + +const LARGE_CONTEXT_THRESHOLD = 200_000; + +type ResolvedModel = + | { custom: true } + | { + custom: false; + providerKey: string; + modelKey: string; + model: ModelsDevModel; + }; + +const modelNotFoundError = (modelName: string) => + new RecaseError({ + message: `Model ${modelName} not found in models.dev pricing data`, + code: ErrCode.InvalidRequest, + statusCode: 400, + data: { modelName }, + }); + +/** + * Resolve a `model_id` to a models.dev entry by exact `/` lookup. The id is + * split on the first `/` (so openrouter slugs like `openrouter/openai/gpt-4o` keep their inner + * `/`); the model key must match a models.dev entry exactly. `custom/` models skip resolution. + */ +const resolveModel = ({ + modelName, + pricingData, +}: { + modelName: string; + pricingData: ModelPricingData; +}): ResolvedModel => { + if (isCustomModel(modelName)) { + return { custom: true }; + } + + const { provider, modelKey } = splitModelId(modelName); + const model = provider ? pricingData[provider]?.models[modelKey] : undefined; + if (!(provider && model)) { + throw modelNotFoundError(modelName); + } + + return { custom: false, providerKey: provider, modelKey, model }; +}; + +/** + * Resolve the effective per-token rates for a request, overlaying the active long-context + * tier (or `context_over_200k`) onto the base rates. Tier-level `cache_read`/`cache_write` + * override the base cache rates when present, so cache tokens above the threshold are billed + * at the tier rate too — not just input/output. + */ +const getEffectiveCost = ( + cost: ModelsDevCost, + totalInputTokens: number, +): { effective: ModelsDevCost; tierApplied: boolean } => { + if (cost.tiers?.length) { + let chosen: ModelsDevCostTier | undefined; + for (const tier of cost.tiers) { + if ( + totalInputTokens > tier.tier.size && + (!chosen || tier.tier.size > chosen.tier.size) + ) { + chosen = tier; + } + } + if (chosen) { + return { + 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 { effective: cost, tierApplied: false }; + } + if (cost.context_over_200k && totalInputTokens > LARGE_CONTEXT_THRESHOLD) { + return { + 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 { 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 = ({ + cost, + tokens, + markup, +}: { + cost: ModelsDevCost; + tokens: TokenInput; + markup: number; +}): { cost: number; baseCost: number; tierApplied: boolean; rates: ModelCostRates } => { + const cacheRead = tokens.cacheRead ?? 0; + const cacheWrite = tokens.cacheWrite ?? 0; + const audioInput = tokens.audioInput ?? 0; + const audioOutput = tokens.audioOutput ?? 0; + const reasoning = tokens.reasoning ?? 0; + + const totalInput = tokens.input + cacheRead + cacheWrite; + const { effective, tierApplied } = getEffectiveCost(cost, totalInput); + + // Pools without a published rate fall back to the base text rate. + 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, + }; + + const baseCost = new Decimal(rates.input) + .mul(tokens.input) + .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 = ({ + modelName, + creditSystem, + modelMarkup, +}: { + modelName: string; + creditSystem: Feature; + modelMarkup?: { markup?: number | null } | null; +}): { markup: number; source: ModelCostBreakdown["markupSource"] } => { + if (modelMarkup?.markup != null) { + 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" }; + } + + const defaultMarkup = creditSystem.config?.default_markup; + if (defaultMarkup != null) { + return { markup: defaultMarkup, source: "default" }; + } + + return { markup: 0, source: "none" }; +}; + +export const getModelCreditCostBreakdown = async ({ + modelName, + creditSystem, + ...tokens +}: { + modelName: string; + creditSystem: Feature; +} & TokenInput): Promise => { + const markups = creditSystem.model_markups || {}; + const pricingData = await getModelsDevPricing(); + const resolved = resolveModel({ modelName, pricingData }); + + const markupEntry = markups[modelName]; + const { markup, source } = resolveAiMarkup({ + modelName, + creditSystem, + modelMarkup: markupEntry, + }); + + // Custom models carry no models.dev rates; they bill input/output at the user-supplied + // costs only (cache/audio/reasoning pools are not priced for custom models). + if (resolved.custom) { + if (markupEntry?.input_cost == null || markupEntry?.output_cost == null) { + throw new RecaseError({ + message: `Custom model ${modelName} is missing input_cost or output_cost in model_markups`, + code: ErrCode.InvalidRequest, + data: { modelName }, + }); + } + 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 }; + } + + 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 12a0363ad..e9ddee717 100644 --- a/server/src/internal/features/creditSystemUtils.ts +++ b/server/src/internal/features/creditSystemUtils.ts @@ -1,7 +1,11 @@ import { type CreditSchemaItem, + ErrCode, type Feature, FeatureType, + isAiCreditSystem, + isAnyCreditSystem, + RecaseError, } from "@autumn/shared"; import { Decimal } from "decimal.js"; @@ -15,7 +19,8 @@ const creditSystemContainsFeature = ({ if (creditSystem.type !== FeatureType.CreditSystem) { return false; } - const schema: CreditSchemaItem[] = creditSystem.config.schema; + const schema: CreditSchemaItem[] | undefined = creditSystem.config?.schema; + if (!schema) return false; for (const schemaItem of schema) { if (schemaItem.metered_feature_id === meteredFeatureId) { @@ -70,6 +75,7 @@ export const featureToCreditSystem = ({ return amount; }; +/** Sync credit-schema math; token pricing (models.dev I/O) lives in getModelCreditCost. */ export const getCreditCost = ({ featureId, creditSystem, @@ -79,11 +85,22 @@ export const getCreditCost = ({ creditSystem: Feature; amount?: number; }) => { - if (creditSystem.type !== FeatureType.CreditSystem) { + if (!isAnyCreditSystem(creditSystem.type)) { return amount; } + // 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) { return new Decimal(schemaItem.credit_amount) @@ -93,5 +110,10 @@ export const getCreditCost = ({ } } - return 1; + throw new RecaseError({ + message: "Feature is not included in credit system schema", + code: ErrCode.InvalidRequest, + statusCode: 400, + data: { featureId, creditSystemId: creditSystem.id }, + }); }; diff --git a/server/src/internal/features/featureActions/createFeature.ts b/server/src/internal/features/featureActions/createFeature.ts index d2622976f..6fd71732e 100644 --- a/server/src/internal/features/featureActions/createFeature.ts +++ b/server/src/internal/features/featureActions/createFeature.ts @@ -1,23 +1,35 @@ -import { CreateFeatureSchema, type Feature, FeatureType } from "@autumn/shared"; +import { + CreateFeatureSchema, + type Feature, + FeatureType, + isAnyCreditSystem, + type ModelMarkups, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { workflows } from "@/queue/workflows.js"; import { generateId } from "@/utils/genUtils.js"; import { FeatureService } from "../FeatureService.js"; import { validateCreditSystem, + validateCreditSystemSchemaReferences, validateMeteredConfig, } from "../featureUtils.js"; -const validateFeature = (data: any) => { - const featureType = data.type; - - // validateFeatureId(data.id); +const validateFeature = (data: any, allFeatures: Feature[]) => { + const featureType = data.type as FeatureType; let config = data.config; if (featureType === FeatureType.Metered) { config = validateMeteredConfig(config); - } else if (featureType === FeatureType.CreditSystem) { - config = validateCreditSystem(config); + } else if (isAnyCreditSystem(featureType)) { + config = validateCreditSystem(config, featureType); + if (featureType === FeatureType.CreditSystem) { + validateCreditSystemSchemaReferences({ + config, + allFeatures, + selfFeatureId: data.id, + }); + } } const parsedFeature = CreateFeatureSchema.parse({ ...data, config }); @@ -32,6 +44,7 @@ interface CreateFeatureParams { type: string; config?: any; event_names?: string[]; + model_markups?: ModelMarkups; }; skipGenerateDisplay?: boolean; } @@ -45,7 +58,7 @@ export const createFeature = async ({ data, skipGenerateDisplay = false, }: CreateFeatureParams): Promise => { - const parsedFeature = validateFeature(data); + const parsedFeature = validateFeature(data, ctx.features); const feature: Feature = { archived: false, @@ -54,6 +67,7 @@ export const createFeature = async ({ created_at: Date.now(), env: ctx.env, ...parsedFeature, + model_markups: data.model_markups ?? null, }; const insertedData = await FeatureService.insert({ diff --git a/server/src/internal/features/featureActions/updateFeature.ts b/server/src/internal/features/featureActions/updateFeature.ts index 1321b5140..f40846c7a 100644 --- a/server/src/internal/features/featureActions/updateFeature.ts +++ b/server/src/internal/features/featureActions/updateFeature.ts @@ -1,8 +1,12 @@ import { type CreditSchemaItem, + type CreditSystemConfig, ErrCode, type Feature, FeatureType, + isAiCreditSystem, + isAnyCreditSystem, + type ModelMarkups, notNullish, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; @@ -13,6 +17,7 @@ import RecaseError from "@/utils/errorUtils.js"; import { FeatureService } from "../FeatureService.js"; import { validateCreditSystem, + validateCreditSystemSchemaReferences, validateMeteredConfig, } from "../featureUtils.js"; import { getObjectsUsingFeature } from "../utils/updateFeatureUtils/getObjectsUsingFeature.js"; @@ -27,6 +32,58 @@ interface UpdateFeatureParams { updates: Partial; } +/** Generic keyed-record equality check with a caller-supplied per-entry comparison. */ +const areMarkupRecordsEqual = ( + a: Record | null | undefined, + b: Record | null | undefined, + entriesEqual: (aEntry: T, bEntry: T) => boolean, +): boolean => { + const aIsAbsent = a == null; + const bIsAbsent = b == null; + if (aIsAbsent && bIsAbsent) return true; + if (aIsAbsent || bIsAbsent) return false; + + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + + for (const key of aKeys) { + const aEntry = a[key]; + const bEntry = b[key]; + if (!bEntry) return false; + if (!entriesEqual(aEntry, bEntry)) return false; + } + + return true; +}; + +const areModelMarkupsEqual = ({ + a, + b, +}: { + a: ModelMarkups; + b: ModelMarkups; +}): boolean => + areMarkupRecordsEqual[string]>( + a, + b, + (aEntry, bEntry) => + aEntry.markup === bEntry.markup && + aEntry.input_cost === bEntry.input_cost && + aEntry.output_cost === bEntry.output_cost, + ); + +const areProviderMarkupsEqual = ({ + a, + b, +}: { + a: CreditSystemConfig["provider_markups"]; + b: CreditSystemConfig["provider_markups"]; +}): boolean => + areMarkupRecordsEqual< + NonNullable[string] + >(a, b, (aEntry, bEntry) => aEntry.markup === bEntry.markup); + /** * Checks if the credit schema has changed between old and new config. * Returns true if schema changed (different items or different credit amounts). @@ -58,6 +115,26 @@ const hasCreditSchemaChanged = ({ return false; }; +const hasAiMarkupConfigChanged = ({ + oldConfig, + newConfig, +}: { + oldConfig: CreditSystemConfig | undefined; + newConfig: CreditSystemConfig | undefined; +}): boolean => { + if ( + (oldConfig?.default_markup ?? undefined) !== + (newConfig?.default_markup ?? undefined) + ) { + return true; + } + + return !areProviderMarkupsEqual({ + a: oldConfig?.provider_markups, + b: newConfig?.provider_markups, + }); +}; + /** * Updates an existing feature with full validation logic */ @@ -146,15 +223,32 @@ export const updateFeature = async ({ } } - // Validate config based on feature type - const newConfig = - updates.config !== undefined - ? feature.type === FeatureType.CreditSystem - ? validateCreditSystem(updates.config) - : feature.type === FeatureType.Metered - ? validateMeteredConfig(updates.config) - : updates.config - : feature.config; + const effectiveType = updates.type ?? feature.type; + + const newConfig = (() => { + if (updates.config === undefined) return feature.config; + switch (effectiveType) { + case FeatureType.AiCreditSystem: + case FeatureType.CreditSystem: { + const validatedConfig = validateCreditSystem( + updates.config, + effectiveType, + ); + if (effectiveType === FeatureType.CreditSystem) { + validateCreditSystemSchemaReferences({ + config: validatedConfig, + allFeatures, + selfFeatureId: updates.id ?? feature.id, + }); + } + return validatedConfig; + } + case FeatureType.Metered: + return validateMeteredConfig(updates.config); + default: + return updates.config; + } + })(); // Update the feature const updatedFeature = await FeatureService.update({ @@ -165,10 +259,11 @@ export const updateFeature = async ({ updates: { id: updates.id, name: updates.name, - type: updates.type, + type: effectiveType, archived: updates.archived, event_names: updates.event_names, config: newConfig, + model_markups: updates.model_markups, }, }); @@ -181,18 +276,32 @@ export const updateFeature = async ({ }); } - // Queue cache clear for credit system if schema changed - if ( - feature.type === FeatureType.CreditSystem && - updates.config?.schema && - updatedFeature - ) { - const schemaChanged = hasCreditSchemaChanged({ - oldSchema: feature.config?.schema, - newSchema: updates.config.schema, - }); + // Queue cache clear for credit system if schema or model markups changed + const isCreditSystem = isAnyCreditSystem(feature.type); + if (isCreditSystem && updatedFeature) { + const schemaChanged = + updates.config != null && + hasCreditSchemaChanged({ + oldSchema: feature.config?.schema, + newSchema: updates.config.schema, + }); - if (schemaChanged) { + const markupsChanged = + updates.model_markups !== undefined && + !areModelMarkupsEqual({ + a: updates.model_markups, + b: feature.model_markups, + }); + + const aiMarkupConfigChanged = + isAiCreditSystem(feature.type) && + updates.config != null && + hasAiMarkupConfigChanged({ + oldConfig: feature.config, + newConfig, + }); + + if (schemaChanged || markupsChanged || aiMarkupConfigChanged) { await addTaskToQueue({ jobName: JobName.ClearCreditSystemCustomerCache, payload: { diff --git a/server/src/internal/features/featureRouter.ts b/server/src/internal/features/featureRouter.ts index 75113786f..75d11b38a 100644 --- a/server/src/internal/features/featureRouter.ts +++ b/server/src/internal/features/featureRouter.ts @@ -10,10 +10,12 @@ import { handleListFeaturesV1 } from "./handlers/handleListFeatures/handleListFe import { handleUpdateFeatureV1 } from "./handlers/handleUpdateFeature/handleUpdateFeatureV1"; import { handleUpdateFeatureV2 } from "./handlers/handleUpdateFeature/handleUpdateFeatureV2"; import { handleGetFeatureDeletionInfo } from "./internalHandlers/handleGetFeatureDeletionInfo"; +import { handleGetModelPricing } from "./internalHandlers/handleGetModelPricing"; export const featureRouter = new Hono(); featureRouter.get("", ...handleListFeaturesV1); featureRouter.post("", ...handleCreateFeatureV1); +featureRouter.get("/ai/model_pricing", ...handleGetModelPricing); featureRouter.get("/:feature_id", ...handleGetFeatureV1); featureRouter.post("/:feature_id", ...handleUpdateFeatureV1); featureRouter.delete("/:feature_id", ...handleDeleteFeatureV1); diff --git a/server/src/internal/features/featureUtils.ts b/server/src/internal/features/featureUtils.ts index 02015399d..35e66c579 100644 --- a/server/src/internal/features/featureUtils.ts +++ b/server/src/internal/features/featureUtils.ts @@ -7,6 +7,7 @@ import { FeatureType, FeatureUsageType, type FullCustomer, + isAiCreditSystem, isAllocatedPrice, type MeteredConfig, type UsagePriceConfig, @@ -41,9 +42,13 @@ export const validateMeteredConfig = (config: MeteredConfig) => { return newConfig as MeteredConfig; }; -export const validateCreditSystem = (config: CreditSystemConfig) => { - const schema = config.schema; - if (!schema || schema.length === 0) { +export const validateCreditSystem = ( + config: CreditSystemConfig, + featureType: FeatureType = FeatureType.CreditSystem, +) => { + const schema = Array.isArray(config?.schema) ? config.schema : []; + + if (!isAiCreditSystem(featureType) && schema.length === 0) { throw new RecaseError({ message: `At least one metered feature is required for credit system`, code: ErrCode.InvalidFeature, @@ -51,11 +56,17 @@ export const validateCreditSystem = (config: CreditSystemConfig) => { }); } - // Check if multiple of the same feature + if (isAiCreditSystem(featureType) && schema.length > 0) { + throw new RecaseError({ + message: `AI credit systems are leaf features and cannot define a schema. Model rates live in model_markups.`, + code: ErrCode.InvalidFeature, + statusCode: 400, + }); + } + const meteredFeatureIds = schema.map( (schemaItem) => schemaItem.metered_feature_id, ); - // console.log("Metered feature ids:", meteredFeatureIds); const uniqueMeteredFeatureIds = Array.from(new Set(meteredFeatureIds)); if (meteredFeatureIds.length !== uniqueMeteredFeatureIds.length) { throw new RecaseError({ @@ -65,7 +76,37 @@ export const validateCreditSystem = (config: CreditSystemConfig) => { }); } - const newConfig = { ...config, usage_type: FeatureUsageType.Single }; + const newConfig = { ...config, schema, usage_type: FeatureUsageType.Single }; + const defaultMarkup = newConfig.default_markup; + if (defaultMarkup != null) { + const parsedDefaultMarkup = Number(defaultMarkup); + if (Number.isNaN(parsedDefaultMarkup) || parsedDefaultMarkup < -100) { + throw new RecaseError({ + message: + "Default markup must be -100 or greater (-100 makes usage free)", + code: ErrCode.InvalidFeature, + statusCode: 400, + }); + } + newConfig.default_markup = parsedDefaultMarkup; + } + + const providerMarkups = newConfig.provider_markups; + if (providerMarkups != null) { + for (const [provider, entry] of Object.entries(providerMarkups)) { + const markup = Number(entry?.markup); + if (!provider || Number.isNaN(markup) || markup < -100) { + throw new RecaseError({ + message: + "Provider markups must be -100 or greater (-100 makes usage free)", + code: ErrCode.InvalidFeature, + statusCode: 400, + }); + } + entry.markup = markup; + } + } + for (let i = 0; i < newConfig.schema.length; i++) { const creditAmount = parseFloat( newConfig.schema[i].credit_amount.toString(), @@ -85,6 +126,43 @@ export const validateCreditSystem = (config: CreditSystemConfig) => { return newConfig; }; +/** + * Validates that every feature referenced by a credit system's schema is a + * Metered or AiCreditSystem feature. Rejects nesting one CreditSystem inside + * another — composition is capped at two levels (parent credit system → leaf). + * + * Self-references are tolerated (the create flow doesn't yet have the new id + * in the features list, and updates simply read back as the feature itself). + */ +export const validateCreditSystemSchemaReferences = ({ + config, + allFeatures, + selfFeatureId, +}: { + config: CreditSystemConfig; + allFeatures: Feature[]; + selfFeatureId?: string; +}) => { + const schema = Array.isArray(config?.schema) ? config.schema : []; + if (schema.length === 0) return; + + for (const item of schema) { + const referencedId = item.metered_feature_id; + if (!referencedId || referencedId === selfFeatureId) continue; + + const referenced = allFeatures.find((f) => f.id === referencedId); + if (!referenced) continue; + + if (referenced.type === FeatureType.CreditSystem) { + throw new RecaseError({ + message: `Credit system schema cannot reference another credit system (${referencedId}). Only metered or AI credit features are allowed.`, + code: ErrCode.InvalidFeature, + statusCode: 400, + }); + } + } +}; + const getCusFeatureType = ({ feature }: { feature: Feature }) => { if (feature.type === FeatureType.Boolean) { return ApiFeatureType.Static; diff --git a/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV1.ts b/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV1.ts index 17fceaaea..3a0bb0631 100644 --- a/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV1.ts +++ b/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV1.ts @@ -58,6 +58,7 @@ export const handleUpdateFeatureV1 = createRoute({ archived: body.archived, event_names: body.event_names, display: body.display, + model_markups: body.model_markups, }, }); diff --git a/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV2.ts b/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV2.ts index 80eae8d37..dba75e714 100644 --- a/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV2.ts +++ b/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV2.ts @@ -55,6 +55,7 @@ export const handleUpdateFeatureV2 = createRoute({ archived: body.archived, event_names: body.event_names, display: body.display, + model_markups: body.model_markups, }, }); diff --git a/server/src/internal/features/internalHandlers/handleGetModelPricing.ts b/server/src/internal/features/internalHandlers/handleGetModelPricing.ts new file mode 100644 index 000000000..91f9ba866 --- /dev/null +++ b/server/src/internal/features/internalHandlers/handleGetModelPricing.ts @@ -0,0 +1,11 @@ +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { getModelsDevPricing } from "@/internal/features/utils/getModelPricing"; +import { Scopes } from "@autumn/shared"; + +export const handleGetModelPricing = createRoute({ + scopes: [Scopes.Features.Read], + handler: async (c) => { + const data = await getModelsDevPricing(); + return c.json(data); + }, +}); diff --git a/server/src/internal/features/utils/constructFeatureUtils.ts b/server/src/internal/features/utils/constructFeatureUtils.ts index 7ffab0b81..ca5562895 100644 --- a/server/src/internal/features/utils/constructFeatureUtils.ts +++ b/server/src/internal/features/utils/constructFeatureUtils.ts @@ -1,9 +1,12 @@ import { AggregateType, type AppEnv, + buildAiCreditSystemConfig, type Feature, FeatureType, FeatureUsageType, + type ModelMarkups, + type ProviderMarkups, } from "@autumn/shared"; import { generateId, keyToTitle } from "@server/utils/genUtils"; @@ -36,8 +39,9 @@ const constructFeature = ({ display, archived: false, event_names: [], + model_markups: null, }; - + // This function isn't used anywhere, maybe delete it? return newFeature; }; @@ -64,6 +68,7 @@ export const constructBooleanFeature = ({ config: null, archived: false, event_names: [], + model_markups: null, }; return newFeature; @@ -109,6 +114,7 @@ export const constructMeteredFeature = ({ }, archived: false, event_names: eventNames, + model_markups: null, }; return newFeature; @@ -151,6 +157,44 @@ export const constructCreditSystem = ({ config, archived: false, event_names: [], + model_markups: null, + }; + + return newFeature; +}; + +export const constructAiCreditSystem = ({ + featureId, + name, + orgId, + env, + modelMarkups, + defaultMarkup, + providerMarkups, +}: { + featureId: string; + name?: string; + orgId: string; + env: AppEnv; + modelMarkups: ModelMarkups; + defaultMarkup?: number; + providerMarkups?: ProviderMarkups; +}) => { + const config = buildAiCreditSystemConfig({ defaultMarkup, providerMarkups }); + + const newFeature: Feature = { + internal_id: generateId("fe"), + org_id: orgId, + env, + created_at: Date.now(), + + id: featureId, + name: name || keyToTitle(featureId), + type: FeatureType.AiCreditSystem, + config, + archived: false, + event_names: [], + model_markups: modelMarkups, }; return newFeature; diff --git a/server/src/internal/features/utils/getModelPricing.ts b/server/src/internal/features/utils/getModelPricing.ts new file mode 100644 index 000000000..0b4cdf0a9 --- /dev/null +++ b/server/src/internal/features/utils/getModelPricing.ts @@ -0,0 +1,43 @@ +import { CacheManager } from "@/utils/cacheUtils/CacheManager.js"; +import { ErrCode, InternalError, type ModelsDevProvider } from "@autumn/shared"; + +type ModelPricingData = Record; + +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", { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new InternalError({ + message: `models.dev returned ${response.status}`, + code: ErrCode.InternalError, + }); + } + return response.json(); +}; + +export const getModelsDevPricing = async (): Promise => { + const cached = await CacheManager.getJson(CACHE_KEY); + if (cached) return cached; + + try { + const data = await fetchFromSource(); + CacheManager.setJson(CACHE_KEY, data, TTL_PRIMARY).catch(() => {}); + CacheManager.setJson(STALE_KEY, data, TTL_STALE).catch(() => {}); + return data; + } catch { + const stale = await CacheManager.getJson(STALE_KEY); + if (stale) return stale; + throw new InternalError({ + message: "Failed to fetch models.dev pricing and no cache available", + code: ErrCode.InternalError, + }); + } +}; 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 037f70a11..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({ @@ -154,7 +154,7 @@ export const setupUpdatePlanProductContext = async ({ 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/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/fullSubjectGateEdgeConfig/fullSubjectGateEdgeConfigStore.ts b/server/src/internal/misc/fullSubjectGateEdgeConfig/fullSubjectGateEdgeConfigStore.ts index c1047be94..0a953e5dd 100644 --- a/server/src/internal/misc/fullSubjectGateEdgeConfig/fullSubjectGateEdgeConfigStore.ts +++ b/server/src/internal/misc/fullSubjectGateEdgeConfig/fullSubjectGateEdgeConfigStore.ts @@ -33,7 +33,10 @@ export const updateFullSubjectGateConfig = async ({ export const _setFullSubjectGateConfigForTesting = ({ config, }: { - config: FullSubjectGateEdgeConfig; + config: Partial; }): void => { - store._setRuntimeConfigForTesting(config); + store._setRuntimeConfigForTesting({ + ...FullSubjectGateEdgeConfigSchema.parse({}), + ...config, + }); }; 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 52a5f9cf1..821e6f2dd 100644 --- a/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts +++ b/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts @@ -13,8 +13,23 @@ export enum RateLimitType { ListCustomers = "list_customers", CustomerEntitiesGet = "customer_entities_get", Logs = "logs", + TrackOrg = "track_org", + CheckOrg = "check_org", + EntitiesGetOrg = "entities_get_org", } +// Org-wide aggregate caps summed across all of an org's customers — the +// per-customer limits never bind for many-customer storms (2026-06-08 incident). +const ORG_AGGREGATE_TYPES: Partial> = { + [RateLimitType.Track]: RateLimitType.TrackOrg, + [RateLimitType.Check]: RateLimitType.CheckOrg, + [RateLimitType.CustomerEntitiesGet]: RateLimitType.EntitiesGetOrg, +}; + +export const getOrgAggregateType = ( + type: RateLimitType, +): RateLimitType | undefined => ORG_AGGREGATE_TYPES[type]; + type RoutePattern = { method: string; url: string; @@ -120,6 +135,22 @@ const RATE_LIMIT_ROUTE_GROUPS: RateLimitRouteGroup[] = [ }, ]; +// Check-group routes that can fail open (allowed: true) when an org is over +// its aggregate cap; the establish routes in the group shed a 503 instead. +const CHECK_FAIL_OPEN_PATTERNS: RoutePattern[] = [ + route({ method: "POST", url: "/v1/check" }), + route({ method: "POST", url: "/v1/entitled" }), + route({ method: "POST", url: "/v1/balances.check" }), +]; + +export const isCheckFailOpenRoute = (c: Context): boolean => { + const method = c.req.method; + const path = c.req.path; + return CHECK_FAIL_OPEN_PATTERNS.some((pattern) => + matchRoute({ url: path, method, pattern }), + ); +}; + export const getRateLimitType = (c: Context) => { const method = c.req.method; const path = c.req.path; @@ -154,6 +185,9 @@ export type RateLimitConfig = { windowMs: number; notInRedis: boolean; scope: RateLimitScope; + // "degrade" = over-limit requests fail open (check -> allow, track -> SQS + // queue) instead of 429, so the cap sheds DB load without losing events. + overLimit?: "reject" | "degrade"; }; export const resolveRateLimit = ({ @@ -255,4 +289,29 @@ export const RATE_LIMIT_CONFIGS: Record = { notInRedis: false, scope: RateLimitScope.Org, }, + // 60s windows sized ~1.5-2x the highest legit per-org peak observed over 7d + // of prod traffic (check 157k/min, track 60k/min, entities.get 53k/min). + [RateLimitType.TrackOrg]: { + name: "track_org", + limit: 120_000, + windowMs: 60_000, + notInRedis: false, + scope: RateLimitScope.Org, + overLimit: "degrade", + }, + [RateLimitType.CheckOrg]: { + name: "check_org", + limit: 240_000, + windowMs: 60_000, + notInRedis: false, + scope: RateLimitScope.Org, + overLimit: "degrade", + }, + [RateLimitType.EntitiesGetOrg]: { + name: "entities_get_org", + limit: 90_000, + windowMs: 60_000, + notInRedis: false, + scope: RateLimitScope.Org, + }, }; diff --git a/server/src/internal/misc/rateLimiter/rateLimitFactory.ts b/server/src/internal/misc/rateLimiter/rateLimitFactory.ts index 7ae7fe61a..5371fa09f 100644 --- a/server/src/internal/misc/rateLimiter/rateLimitFactory.ts +++ b/server/src/internal/misc/rateLimiter/rateLimitFactory.ts @@ -1,14 +1,15 @@ import type { ApiVersion } from "@autumn/shared"; -import type { Context } from "hono"; +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"; import type { HonoEnv } from "@/honoUtils/HonoEnv"; import { + isCheckFailOpenRoute, RATE_LIMIT_CONFIGS, type RateLimitConfig, RateLimitScope, - type RateLimitType, + RateLimitType, resolveRateLimit, } from "./rateLimitConfigs"; import { getOrgRateLimitOverride } from "./rateLimitOverridesStore"; @@ -60,11 +61,38 @@ export const rateLimitFactory = ({ return resolveRateLimit({ config, apiVersion }).limit; }; + // Over-limit "degrade": fail open instead of 429 — check routes get the + // allow-fallback via the ctx flag; establish routes shed a retryable 503. + const degradeHandler = async ( + c: Context, + next: Next, + ): Promise => { + const honoContext = c as Context; + const ctx = honoContext.get("ctx"); + + if (type === RateLimitType.CheckOrg && !isCheckFailOpenRoute(honoContext)) { + return c.json( + { + message: "Service is temporarily unavailable, please retry shortly.", + code: "service_unavailable", + env: ctx?.env, + }, + 503, + ); + } + + if (ctx) ctx.orgRateLimitDegraded = true; + c.header("Retry-After", undefined); + await next(); + return; + }; + const options = { windowMs, limit: dynamicLimit, standardHeaders: "draft-6" as const, keyGenerator: getRateLimitKeyFromContext, + ...(config.overLimit === "degrade" && { handler: degradeHandler }), }; let inMemoryLimiter: ReturnType | null = null; 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/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/tests/_groups/core/coreAttach.ts b/server/tests/_groups/core/coreAttach.ts index 223e5ca48..d14c4ce12 100644 --- a/server/tests/_groups/core/coreAttach.ts +++ b/server/tests/_groups/core/coreAttach.ts @@ -27,5 +27,7 @@ export const coreAttach: TestGroup = { "billing/attach/discounts/attach-discounts-basic.test.ts", "billing/attach/new-billing-subscription/new-billing-subscription.test.ts", "billing/attach/params/custom-plan/custom-plan-features.test.ts", + "billing/attach/params/billing-cycle-anchor/anchor-reset-refund/anchor-reset-with-carry-over.test.ts", + "billing/attach/params/start-date/starts-at-backdate.test.ts", ], }; diff --git a/server/tests/_groups/core/coreBillingOthers.ts b/server/tests/_groups/core/coreBillingOthers.ts index 2ad96ef06..d19387871 100644 --- a/server/tests/_groups/core/coreBillingOthers.ts +++ b/server/tests/_groups/core/coreBillingOthers.ts @@ -10,6 +10,7 @@ export const coreBillingOthers: TestGroup = { "billing/multi-attach/checkout/multi-attach-customize.test.ts", "billing/multi-attach/multi-attach-errors.test.ts", "billing/multi-attach/multi-attach-trial.test.ts", + "billing/create-schedule/phases/create-schedule-phases.test.ts", // Setup payment "billing/setup-payment", diff --git a/server/tests/advanced/creditSystems/ai-markup-resolution.test.ts b/server/tests/advanced/creditSystems/ai-markup-resolution.test.ts new file mode 100644 index 000000000..39a9b710c --- /dev/null +++ b/server/tests/advanced/creditSystems/ai-markup-resolution.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import { + type Feature, + FeatureType, + FeatureUsageType, + type ModelMarkups, + type ProviderMarkups, +} from "@autumn/shared"; +import { getModelCreditCost } from "@/internal/features/aiCreditSystemUtils.js"; + +// 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 +const BASE_COST = 2.0; +const INPUT_COST = 1000; +const OUTPUT_COST = 2000; + +const makeAiCredit = ({ + model_markups, + default_markup, + provider_markups, +}: { + model_markups: ModelMarkups; + default_markup?: number; + provider_markups?: ProviderMarkups; +}): 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, + default_markup, + provider_markups, + }, + archived: false, + event_names: [], + model_markups, +}); + +const cost = (creditSystem: Feature) => + getModelCreditCost({ + modelName: CUSTOM_MODEL, + creditSystem, + ...TOKENS, + }); + +describe("getModelCreditCost — tiered AI markup resolution", () => { + test("per-model markup wins over provider and global", async () => { + const creditSystem = makeAiCredit({ + model_markups: { + [CUSTOM_MODEL]: { + input_cost: INPUT_COST, + output_cost: OUTPUT_COST, + markup: 50, + }, + }, + provider_markups: { custom: { markup: 20 } }, + default_markup: 10, + }); + expect(await cost(creditSystem)).toBeCloseTo(BASE_COST * 1.5); + }); + + test("falls back to provider markup when model markup is omitted", async () => { + const creditSystem = makeAiCredit({ + model_markups: { + [CUSTOM_MODEL]: { input_cost: INPUT_COST, output_cost: OUTPUT_COST }, + }, + provider_markups: { custom: { markup: 20 } }, + default_markup: 10, + }); + expect(await cost(creditSystem)).toBeCloseTo(BASE_COST * 1.2); + }); + + test("falls back to global default markup when model and provider are omitted", async () => { + const creditSystem = makeAiCredit({ + model_markups: { + [CUSTOM_MODEL]: { input_cost: INPUT_COST, output_cost: OUTPUT_COST }, + }, + default_markup: 10, + }); + expect(await cost(creditSystem)).toBeCloseTo(BASE_COST * 1.1); + }); + + test("bills at base cost (1:1) when no markup is configured anywhere", async () => { + const creditSystem = makeAiCredit({ + model_markups: { + [CUSTOM_MODEL]: { input_cost: INPUT_COST, output_cost: OUTPUT_COST }, + }, + }); + expect(await cost(creditSystem)).toBeCloseTo(BASE_COST); + }); + + test("explicit per-model markup of 0 overrides provider and global", async () => { + const creditSystem = makeAiCredit({ + model_markups: { + [CUSTOM_MODEL]: { + input_cost: INPUT_COST, + output_cost: OUTPUT_COST, + markup: 0, + }, + }, + provider_markups: { custom: { markup: 20 } }, + default_markup: 10, + }); + expect(await cost(creditSystem)).toBeCloseTo(BASE_COST); + }); +}); diff --git a/server/tests/advanced/creditSystems/ai-model-resolution.test.ts b/server/tests/advanced/creditSystems/ai-model-resolution.test.ts new file mode 100644 index 000000000..bb6830d2d --- /dev/null +++ b/server/tests/advanced/creditSystems/ai-model-resolution.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + type Feature, + FeatureType, + FeatureUsageType, + type ModelMarkups, + type ModelsDevProvider, +} from "@autumn/shared"; + +// Stub the models.dev fetch so resolution + pricing are deterministic and offline. +const pricingData: Record = { + anthropic: { + id: "anthropic", + name: "Anthropic", + models: { + "claude-opus-4-5": { + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + cost: { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 }, + }, + "claude-3-5-haiku-20241022": { + id: "claude-3-5-haiku-20241022", + name: "Claude 3.5 Haiku", + cost: { input: 1, output: 5 }, + }, + }, + }, + openai: { + id: "openai", + name: "OpenAI", + models: { + "gpt-4o-2024-08-06": { + id: "gpt-4o-2024-08-06", + name: "GPT-4o", + cost: { input: 2.5, output: 10, cache_read: 1.25 }, + }, + "gpt-5": { + id: "gpt-5", + name: "GPT-5", + cost: { + input: 1, + output: 2, + cache_read: 0.5, + tiers: [ + { + input: 2, + output: 4, + cache_read: 1, + tier: { type: "context", size: 200_000 }, + }, + ], + context_over_200k: { input: 2, output: 4, cache_read: 1 }, + }, + }, + "omni-audio": { + id: "omni-audio", + name: "Omni Audio", + cost: { + input: 2, + output: 4, + input_audio: 3.5, + output_audio: 7, + reasoning: 10, + }, + }, + "no-cache-model": { + id: "no-cache-model", + name: "No Cache", + cost: { input: 10, output: 20 }, + }, + }, + }, + openrouter: { + id: "openrouter", + name: "OpenRouter", + models: { + // Openrouter keys contain a subprovider slash; the id keeps it after the + // first `/` split (openrouter/openai/gpt-4o-2024-08-06). + "openai/gpt-4o-2024-08-06": { + id: "openai/gpt-4o-2024-08-06", + name: "GPT-4o (OpenRouter)", + cost: { input: 3, output: 12 }, + }, + }, + }, +}; + +mock.module("@/internal/features/utils/getModelPricing.js", () => ({ + getModelsDevPricing: async () => pricingData, +})); + +const { getModelCreditCost, getModelCreditCostBreakdown } = await import( + "@/internal/features/aiCreditSystemUtils.js" +); + +const makeFeature = (model_markups: ModelMarkups = {}): Feature => ({ + internal_id: "fe_ai", + 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, +}); + +const PER_MILLION = 1_000_000; + +describe("resolveModel — exact match", () => { + test("exact provider-scoped match", async () => { + const cost = await getModelCreditCost({ + modelName: "anthropic/claude-opus-4-5", + creditSystem: makeFeature(), + input: 1000, + output: 500, + }); + expect(cost).toBeCloseTo((5 * 1000 + 25 * 500) / PER_MILLION, 10); + }); + + test("openrouter slug keeps its inner slash (split on the first '/')", async () => { + const cost = await getModelCreditCost({ + modelName: "openrouter/openai/gpt-4o-2024-08-06", + creditSystem: makeFeature(), + input: 1000, + output: 0, + }); + // openrouter's own rate (3), not openai's direct rate (2.5). + expect(cost).toBeCloseTo((3 * 1000) / PER_MILLION, 10); + }); + + test("throws when the model key does not match exactly", async () => { + expect( + getModelCreditCost({ + modelName: "anthropic/claude-3-5-haiku", + creditSystem: makeFeature(), + input: 1, + output: 1, + }), + ).rejects.toThrow(/not found/); + }); + + test("throws for a bare name with no provider", async () => { + expect( + getModelCreditCost({ + modelName: "gpt-4o", + creditSystem: makeFeature(), + input: 1, + output: 1, + }), + ).rejects.toThrow(/not found/); + }); +}); + +describe("computeCost — token pools", () => { + test("bills cache read/write at their own rates", async () => { + const cost = await getModelCreditCost({ + modelName: "anthropic/claude-opus-4-5", + creditSystem: makeFeature(), + input: 1000, + output: 500, + cacheRead: 2000, + cacheWrite: 100, + }); + const expected = + (5 * 1000 + 25 * 500 + 0.5 * 2000 + 6.25 * 100) / PER_MILLION; + expect(cost).toBeCloseTo(expected, 10); + }); + + test("uses the long-context tier once the input exceeds the threshold", async () => { + const large = await getModelCreditCost({ + modelName: "openai/gpt-5", + creditSystem: makeFeature(), + input: 300_000, + output: 1000, + }); + expect(large).toBeCloseTo((2 * 300_000 + 4 * 1000) / PER_MILLION, 10); + + const small = await getModelCreditCost({ + modelName: "openai/gpt-5", + creditSystem: makeFeature(), + input: 100_000, + output: 1000, + }); + expect(small).toBeCloseTo((1 * 100_000 + 2 * 1000) / PER_MILLION, 10); + }); + + test("bills cache at the tier rate above the context threshold", async () => { + const above = await getModelCreditCost({ + modelName: "openai/gpt-5", + creditSystem: makeFeature(), + input: 300_000, + output: 0, + cacheRead: 1000, + }); + // totalInput 301k > 200k -> tier input (2) and tier cache_read (1), not base 0.5. + expect(above).toBeCloseTo((2 * 300_000 + 1 * 1000) / PER_MILLION, 10); + + const below = await getModelCreditCost({ + modelName: "openai/gpt-5", + creditSystem: makeFeature(), + input: 1000, + output: 0, + cacheRead: 1000, + }); + // totalInput 2k < 200k -> base input (1) and base cache_read (0.5). + expect(below).toBeCloseTo((1 * 1000 + 0.5 * 1000) / PER_MILLION, 10); + }); + + test("bills audio and reasoning pools at their modality rates", async () => { + const cost = await getModelCreditCost({ + modelName: "openai/omni-audio", + creditSystem: makeFeature(), + input: 1000, + output: 500, + audioInput: 200, + audioOutput: 100, + reasoning: 50, + }); + const expected = + (2 * 1000 + 4 * 500 + 3.5 * 200 + 7 * 100 + 10 * 50) / PER_MILLION; + expect(cost).toBeCloseTo(expected, 10); + }); + + test("falls back to the base input rate for missing cache rates", async () => { + const cost = await getModelCreditCost({ + modelName: "openai/no-cache-model", + creditSystem: makeFeature(), + input: 0, + output: 0, + cacheRead: 1000, + }); + // no published cache_read rate -> bill at the base input rate (10) + expect(cost).toBeCloseTo((10 * 1000) / PER_MILLION, 10); + }); + + test("applies markup to the full total", async () => { + const cost = await getModelCreditCost({ + modelName: "anthropic/claude-opus-4-5", + creditSystem: makeFeature({ + "anthropic/claude-opus-4-5": { markup: 50 }, + }), + input: 1000, + output: 500, + }); + 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/creditSystems/validate-credit-system.test.ts b/server/tests/advanced/creditSystems/validate-credit-system.test.ts new file mode 100644 index 000000000..be4a4e521 --- /dev/null +++ b/server/tests/advanced/creditSystems/validate-credit-system.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import { type Feature, FeatureType, FeatureUsageType } from "@autumn/shared"; +import { + validateCreditSystem, + validateCreditSystemSchemaReferences, +} from "@/internal/features/featureUtils.js"; + +const makeFeature = (id: string, type: FeatureType): Feature => ({ + internal_id: `fe_${id}`, + org_id: "org_test", + created_at: Date.now(), + env: "sandbox" as Feature["env"], + id, + name: id, + type, + config: {}, + archived: false, + event_names: [], + model_markups: null, +}); + +describe("validateCreditSystem — AI credit system schema restrictions", () => { + test("rejects an AI credit system with a non-empty schema", () => { + expect(() => + validateCreditSystem( + { + schema: [{ metered_feature_id: "messages", credit_amount: 1 }] as any, + usage_type: FeatureUsageType.Single, + }, + FeatureType.AiCreditSystem, + ), + ).toThrow(/leaf features/); + }); + + test("allows an AI credit system with an empty schema", () => { + const result = validateCreditSystem( + { + schema: [], + usage_type: FeatureUsageType.Single, + }, + FeatureType.AiCreditSystem, + ); + expect(result.schema).toHaveLength(0); + }); + + test("rejects a regular credit system with empty schema", () => { + expect(() => + validateCreditSystem( + { schema: [], usage_type: FeatureUsageType.Single }, + FeatureType.CreditSystem, + ), + ).toThrow(/At least one metered feature/); + }); +}); + +describe("validateCreditSystemSchemaReferences — cross-feature restrictions", () => { + const metered = makeFeature("messages", FeatureType.Metered); + const aiCredit = makeFeature("ai_credits", FeatureType.AiCreditSystem); + const otherCreditSystem = makeFeature("orbs", FeatureType.CreditSystem); + + test("allows referencing a metered feature", () => { + expect(() => + validateCreditSystemSchemaReferences({ + config: { + schema: [{ metered_feature_id: "messages", credit_amount: 1 } as any], + usage_type: FeatureUsageType.Single, + }, + allFeatures: [metered], + }), + ).not.toThrow(); + }); + + test("allows referencing an AI credit system", () => { + expect(() => + validateCreditSystemSchemaReferences({ + config: { + schema: [ + { metered_feature_id: "ai_credits", credit_amount: 1000 } as any, + ], + usage_type: FeatureUsageType.Single, + }, + allFeatures: [aiCredit], + }), + ).not.toThrow(); + }); + + test("rejects referencing another credit system (prevents nesting)", () => { + expect(() => + validateCreditSystemSchemaReferences({ + config: { + schema: [{ metered_feature_id: "orbs", credit_amount: 1 } as any], + usage_type: FeatureUsageType.Single, + }, + allFeatures: [otherCreditSystem], + }), + ).toThrow(/cannot reference another credit system/); + }); + + test("self-reference (id matches selfFeatureId) is tolerated", () => { + expect(() => + validateCreditSystemSchemaReferences({ + config: { + schema: [{ metered_feature_id: "self_id", credit_amount: 1 } as any], + usage_type: FeatureUsageType.Single, + }, + allFeatures: [makeFeature("self_id", FeatureType.CreditSystem)], + selfFeatureId: "self_id", + }), + ).not.toThrow(); + }); + + test("dangling reference (id not in allFeatures) is tolerated", () => { + expect(() => + validateCreditSystemSchemaReferences({ + config: { + schema: [ + { metered_feature_id: "nonexistent", credit_amount: 1 } as any, + ], + usage_type: FeatureUsageType.Single, + }, + allFeatures: [], + }), + ).not.toThrow(); + }); +}); 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/integration/balances/track/basic/track-deductions.test.ts b/server/tests/integration/balances/track/basic/track-deductions.test.ts index cdf3bd0b0..55eb828be 100644 --- a/server/tests/integration/balances/track/basic/track-deductions.test.ts +++ b/server/tests/integration/balances/track/basic/track-deductions.test.ts @@ -288,7 +288,9 @@ test.concurrent( const creditFeature = ctx.features.find( (f) => f.id === TestFeature.Credits, ); - expect(creditFeature).toBeDefined(); + if (!creditFeature) { + throw new Error(`${TestFeature.Credits} feature not found`); + } const customerBefore = await autumnV1.customers.get(customerId); @@ -306,7 +308,7 @@ test.concurrent( const overflowAmount = 50; const expectedCreditCost = getCreditCost({ featureId: TestFeature.Action1, - creditSystem: creditFeature!, + 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 new file mode 100644 index 000000000..77f2a4576 --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-tokens-orbs.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "bun:test"; + +import type { ApiCustomerV3, TrackResponseV2 } 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"; + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-ORBS: AI credit system nested inside a parent credit system +// +// 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-1: AI balance covers the cost — parent orbs untouched")}`, + 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, autumnV2 } = await initScenario({ + customerId: "track-tokens-orbs-1", + 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% + const inputTokens = 10_000; + const outputTokens = 5_000; + const expectedUsdCost = new Decimal(5) + .mul(inputTokens) + .add(new Decimal(15).mul(outputTokens)) + .div(1_000_000) + .toNumber(); // 0.125 + + const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: inputTokens, + output_tokens: outputTokens, + }); + + expect(trackRes.customer_id).toBe(customerId); + expect(trackRes.value).toBeCloseTo(expectedUsdCost, 10); + + const customer = await autumnV1.customers.get(customerId); + + // AI credit feature balance dropped by USD cost (1:1) + expect(customer.features[TestFeature.AiCredits]).toMatchObject({ + balance: new Decimal(100).minus(expectedUsdCost).toNumber(), + usage: expectedUsdCost, + }); + + // AI balance covered the full cost, so the parent overflow pool is untouched + expect(customer.features[TestFeature.Orbs]).toMatchObject({ + 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-tiered.test.ts b/server/tests/integration/balances/track/basic/track-tokens-tiered.test.ts new file mode 100644 index 000000000..8dec5f359 --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-tokens-tiered.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; + +import type { ApiCustomerV3, TrackResponseV2 } 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"; + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-TIERED: per-model override vs provider markup fallback +// +// Uses custom/* models so pricing is deterministic (no models.dev fetch). +// AiCreditsTiered config: defaultMarkup=10, providerMarkups.custom=30. +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-tiered: per-model override wins over provider markup fallback")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCreditsTiered, + includedUsage: 1000, + }); + const freeProd = products.base({ + id: "free", + items: [aiCreditsItem], + }); + + const { customerId, autumnV1, autumnV2 } = await initScenario({ + customerId: "track-tokens-tiered", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + const inputTokens = 10000; + const outputTokens = 5000; + // base = (10000 * 10 + 5000 * 20) / 1_000_000 = 0.2 + const baseCost = new Decimal(10) + .mul(inputTokens) + .add(new Decimal(20).mul(outputTokens)) + .div(1_000_000); + + // Per-model override of 5% wins over provider (30%) and global (10%). + const overrideCost = baseCost.mul(1.05).toNumber(); // 0.21 + const overrideRes: TrackResponseV2 = await autumnV2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCreditsTiered, + model_id: "custom/override-model", + input_tokens: inputTokens, + output_tokens: outputTokens, + }); + expect(overrideRes.value).toBeCloseTo(overrideCost, 10); + + // No per-model markup -> inherits the "custom" provider markup of 30%. + const providerCost = baseCost.mul(1.3).toNumber(); // 0.26 + const providerRes: TrackResponseV2 = await autumnV2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCreditsTiered, + model_id: "custom/provider-fallback-model", + input_tokens: inputTokens, + output_tokens: outputTokens, + }); + expect(providerRes.value).toBeCloseTo(providerCost, 10); + + const totalCost = new Decimal(overrideCost).plus(providerCost).toNumber(); + const customer = await autumnV1.customers.get(customerId); + expect(customer.features[TestFeature.AiCreditsTiered]).toMatchObject({ + balance: new Decimal(1000).minus(totalCost).toNumber(), + usage: totalCost, + }); + }, +); diff --git a/server/tests/integration/balances/track/basic/track-tokens.test.ts b/server/tests/integration/balances/track/basic/track-tokens.test.ts new file mode 100644 index 000000000..fb891fac8 --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-tokens.test.ts @@ -0,0 +1,583 @@ +import { expect, test } from "bun:test"; + +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 { getModelCreditCost } from "@/internal/features/aiCreditSystemUtils.js"; + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-1: Basic trackTokens with models.dev pricing +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-1: basic trackTokens with models.dev pricing")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 1000, + }); + const freeProd = products.base({ + id: "free", + items: [aiCreditsItem], + }); + + const { customerId, autumnV1, autumnV2, ctx } = await initScenario({ + customerId: "track-tokens-1", + 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`); + } + + const customerBefore = + await autumnV1.customers.get(customerId); + expect(customerBefore.features[TestFeature.AiCredits].balance).toBe(1000); + + const inputTokens = 1000; + const outputTokens = 500; + const modelId = "anthropic/claude-sonnet-4-20250514"; + + const expectedCost = await getModelCreditCost({ + modelName: modelId, + creditSystem: aiCreditFeature, + input: inputTokens, + output: outputTokens, + }); + + const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: modelId, + input_tokens: inputTokens, + output_tokens: outputTokens, + }); + + expect(trackRes.customer_id).toBe(customerId); + expect(trackRes.value).toBeCloseTo(expectedCost, 10); + + const customerAfter = + await autumnV1.customers.get(customerId); + expect(customerAfter.features[TestFeature.AiCredits]).toMatchObject({ + balance: new Decimal(1000).minus(expectedCost).toNumber(), + usage: expectedCost, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-2: Disambiguation error + explicit feature_id resolution +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-2: disambiguation error and explicit feature_id resolution")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 500, + }); + const aiCredits2Item = items.free({ + featureId: TestFeature.AiCredits2, + includedUsage: 500, + }); + const freeProd = products.base({ + id: "free", + items: [aiCreditsItem, aiCredits2Item], + }); + + const { customerId, autumnV1, autumnV2, ctx } = await initScenario({ + customerId: "track-tokens-2", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Without feature_id, should fail with disambiguation error + let error: any; + try { + await autumnV2.post("/track_tokens", { + customer_id: customerId, + model_id: "anthropic/claude-sonnet-4-20250514", + input_tokens: 100, + output_tokens: 50, + }); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toContain("Multiple AI credit system features"); + + // With explicit feature_id, should succeed and only deduct from AiCredits + const aiCreditFeature = ctx.features.find( + (f) => f.id === TestFeature.AiCredits, + ); + if (!aiCreditFeature) { + throw new Error(`${TestFeature.AiCredits} feature not found`); + } + + const inputTokens = 2000; + const outputTokens = 1000; + const modelId = "anthropic/claude-sonnet-4-20250514"; + + const expectedCost = await getModelCreditCost({ + modelName: modelId, + creditSystem: aiCreditFeature, + input: inputTokens, + output: outputTokens, + }); + + const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: modelId, + input_tokens: inputTokens, + output_tokens: outputTokens, + }); + + expect(trackRes.customer_id).toBe(customerId); + expect(trackRes.value).toBeCloseTo(expectedCost, 10); + + const customer = await autumnV1.customers.get(customerId); + expect(customer.features[TestFeature.AiCredits]).toMatchObject({ + balance: new Decimal(500).minus(expectedCost).toNumber(), + usage: expectedCost, + }); + expect(customer.features[TestFeature.AiCredits2]).toMatchObject({ + balance: 500, + usage: 0, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-3: custom/* model pricing (with and without markup) +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-3: custom model pricing with and without markup")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 1000, + }); + const freeProd = products.base({ + id: "free", + items: [aiCreditsItem], + }); + + const { customerId, autumnV1, autumnV2 } = await initScenario({ + customerId: "track-tokens-3", + 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% + const expectedCostNoMarkup = new Decimal(5) + .mul(10000) + .add(new Decimal(15).mul(5000)) + .div(1_000_000) + .toNumber(); // 0.125 + + const trackRes1: TrackResponseV2 = await autumnV2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: 10000, + output_tokens: 5000, + }); + + expect(trackRes1.value).toBeCloseTo(expectedCostNoMarkup, 10); + + // custom/marked-up-model: input_cost=10 $/M, output_cost=30 $/M, markup=50% + const baseCost = new Decimal(10) + .mul(8000) + .add(new Decimal(30).mul(2000)) + .div(1_000_000); + const expectedCostWithMarkup = baseCost + .mul(new Decimal(1).add(new Decimal(50).div(100))) + .toNumber(); // 0.21 + + const trackRes2: TrackResponseV2 = await autumnV2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/marked-up-model", + input_tokens: 8000, + output_tokens: 2000, + }); + + expect(trackRes2.value).toBeCloseTo(expectedCostWithMarkup, 10); + + const totalCost = new Decimal(expectedCostNoMarkup) + .plus(expectedCostWithMarkup) + .toNumber(); + + const customer = await autumnV1.customers.get(customerId); + expect(customer.features[TestFeature.AiCredits]).toMatchObject({ + balance: new Decimal(1000).minus(totalCost).toNumber(), + usage: totalCost, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-4: models.dev pricing with markup + error for non-AI feature +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-4: models.dev markup and non-AI feature_id error")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 1000, + }); + const creditsItem = items.free({ + featureId: TestFeature.Credits, + includedUsage: 100, + }); + const freeProd = products.base({ + id: "free", + items: [aiCreditsItem, creditsItem], + }); + + const { customerId, autumnV1, autumnV2, ctx } = await initScenario({ + customerId: "track-tokens-4", + 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`); + } + + // anthropic/claude-haiku-3.5 has 20% markup in test config + const inputTokens = 50000; + const outputTokens = 10000; + const modelId = "anthropic/claude-3-5-haiku-20241022"; + + const expectedCost = await getModelCreditCost({ + modelName: modelId, + creditSystem: aiCreditFeature, + input: inputTokens, + output: outputTokens, + }); + + const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: modelId, + input_tokens: inputTokens, + output_tokens: outputTokens, + }); + + expect(trackRes.value).toBeCloseTo(expectedCost, 10); + + const customer = await autumnV1.customers.get(customerId); + expect(customer.features[TestFeature.AiCredits]).toMatchObject({ + balance: new Decimal(1000).minus(expectedCost).toNumber(), + usage: expectedCost, + }); + + // Pointing at a regular credit system should fail + let error: any; + try { + await autumnV2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.Credits, + model_id: "anthropic/claude-sonnet-4-20250514", + input_tokens: 100, + output_tokens: 50, + }); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toContain("not an AI credit system"); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-5: Multiple tracks accumulate correctly +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-5: multiple tracks accumulate balance deductions")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 1000, + }); + const freeProd = products.base({ + id: "free", + items: [aiCreditsItem], + }); + + const { customerId, autumnV1, autumnV2, ctx } = await initScenario({ + customerId: "track-tokens-5", + 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`); + } + + // First track: custom/internal-model (input_cost=5, output_cost=15, markup=0%) + const cost1 = await getModelCreditCost({ + modelName: "custom/internal-model", + creditSystem: aiCreditFeature, + input: 5000, + output: 2000, + }); + + await autumnV2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: 5000, + output_tokens: 2000, + }); + + // Second track: custom/marked-up-model (input_cost=10, output_cost=30, markup=50%) + const cost2 = await getModelCreditCost({ + modelName: "custom/marked-up-model", + creditSystem: aiCreditFeature, + input: 3000, + output: 1000, + }); + + await autumnV2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/marked-up-model", + input_tokens: 3000, + output_tokens: 1000, + }); + + const totalCost = new Decimal(cost1).plus(cost2).toNumber(); + + const customer = await autumnV1.customers.get(customerId); + expect(customer.features[TestFeature.AiCredits]).toMatchObject({ + balance: new Decimal(1000).minus(totalCost).toNumber(), + usage: totalCost, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// 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/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/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/params/billing-cycle-anchor/anchor-reset-refund/anchor-reset-with-carry-over.test.ts b/server/tests/integration/billing/attach/params/billing-cycle-anchor/anchor-reset-refund/anchor-reset-with-carry-over.test.ts index 0705ce893..6fd22233a 100644 --- a/server/tests/integration/billing/attach/params/billing-cycle-anchor/anchor-reset-refund/anchor-reset-with-carry-over.test.ts +++ b/server/tests/integration/billing/attach/params/billing-cycle-anchor/anchor-reset-refund/anchor-reset-with-carry-over.test.ts @@ -1,5 +1,9 @@ import { expect, test } from "bun:test"; -import type { ApiCustomerV5, AttachParamsV1Input } from "@autumn/shared"; +import type { + ApiCustomerV5, + AttachParamsV1Input, + AttachPreviewResponse, +} from "@autumn/shared"; import { EntInterval, ProductItemInterval } from "@autumn/shared"; import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; @@ -316,6 +320,68 @@ test.concurrent(`${chalk.yellowBright("anchor-reset-carry-over 4: monthly messag await expectStripeSubscriptionCorrect({ ctx, customerId }); }); +test.concurrent( + `${chalk.yellowBright("anchor-reset-carry-over 4b: monthly -> monthly stored charge (no refund)")}`, + async () => { + const customerId = "anchor-carry-m2m-stored"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV2_2, ctx } = 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: 14 }), + ], + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: premium.id, + billing_cycle_anchor: "now", + proration_behavior: "none", + carry_over_balances: { enabled: true }, + plan_schedule: "immediate", + })) as AttachPreviewResponse; + expect(preview.total).toBe(50); + expect(preview.line_items.every((item) => item.total >= 0)).toBe(true); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: premium.id, + billing_cycle_anchor: "now", + proration_behavior: "none", + carry_over_balances: { enabled: true }, + redirect_mode: "if_required", + plan_schedule: "immediate", + }); + + expect(result.invoice).toBeDefined(); + expect(result.invoice?.total).toBe(50); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); + }, + 300_000, +); + test.concurrent(`${chalk.yellowBright("anchor-reset-carry-over 5: annual messages only (no refund - 0 full years remaining)")}`, async () => { const customerId = "anchor-no-partial-a2a-yearly-ent"; const annualMessages = constructFeatureItem({ diff --git a/server/tests/integration/billing/attach/scheduled-switch/discounts/scheduled-switch-discounts-preview.test.ts b/server/tests/integration/billing/attach/scheduled-switch/discounts/scheduled-switch-discounts-preview.test.ts index de3e7ba75..1d1ef6c3d 100644 --- a/server/tests/integration/billing/attach/scheduled-switch/discounts/scheduled-switch-discounts-preview.test.ts +++ b/server/tests/integration/billing/attach/scheduled-switch/discounts/scheduled-switch-discounts-preview.test.ts @@ -130,7 +130,9 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts-preview 2: fre ).toBe(true); }); -test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts-preview 3: fresh once coupon does not affect next_cycle")}`, async () => { +// Nothing is billed today on a scheduled switch, so a fresh once coupon +// survives to the first invoice after the switch — next_cycle is discounted. +test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts-preview 3: fresh once coupon with no immediate invoice applies to next_cycle")}`, async () => { const customerId = "sched-switch-disc-preview-once"; const pro = products.pro({ @@ -172,12 +174,12 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts-preview 3: fre const nextCycle = expectPreviewNextCycleCorrect({ preview, - total: 20, + total: 16, })!; expect( nextCycle.line_items.some((lineItem) => lineItem.discounts.length > 0), - ).toBe(false); + ).toBe(true); }); test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts-preview 4: fresh 1-month coupon does not affect annual next_cycle")}`, async () => { 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/params/create-schedule-customize.test.ts b/server/tests/integration/billing/create-schedule/params/create-schedule-customize.test.ts index bbfd348b0..f6e3f7bdf 100644 --- 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 @@ -1,11 +1,13 @@ import { expect, test } from "bun:test"; import { + BillingMethod, CusProductStatus, customerEntitlements, customerProducts, ms, schedulePhases, } from "@autumn/shared"; +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"; @@ -16,10 +18,14 @@ import chalk from "chalk"; import { eq } from "drizzle-orm"; import { getCustomerProductEntitlementBalances, + getCustomerProductFeaturePriceAmounts, getCustomerProductPriceAmounts, getRequiredScheduleId, } from "../utils/createScheduleTestHelpers"; +// Contract: V2.2 schedule customize accepts PATCH-style add_items/remove_items. +// Contract: patched items/prices apply to immediate and future cusProducts, including Stripe. + test.concurrent( `${chalk.yellowBright("create-schedule: preserves feature quantity options on created customer products")}`, async () => { @@ -76,6 +82,180 @@ test.concurrent( }, ); +test.concurrent( + `${chalk.yellowBright("create-schedule: patch customize applies to immediate customer products and Stripe")}`, + async () => { + const base = products.base({ + id: "create-schedule-patch-immediate", + items: [ + items.monthlyPrice(), + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyWords({ includedUsage: 50 }), + ], + }); + + const { customerId, autumnV2_2, ctx } = await initScenario({ + customerId: "create-schedule-patch-immediate", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const response = await autumnV2_2.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: Date.now(), + plans: [ + { + plan_id: base.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 42 }), + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [itemsV2.dashboard()], + }, + }, + ], + }, + ], + }); + + const customerProductId = response.phases[0]!.customer_product_ids[0]!; + const customerProduct = await ctx.db.query.customerProducts.findFirst({ + where: eq(customerProducts.id, customerProductId), + }); + + expect(customerProduct?.is_custom).toBe(true); + expect( + await getCustomerProductPriceAmounts({ ctx, customerProductId }), + ).toEqual([42]); + expect( + await getCustomerProductEntitlementBalances({ + ctx, + customerProductId, + }), + ).toEqual( + expect.arrayContaining([ + { feature_id: TestFeature.Words, balance: 50 }, + { feature_id: TestFeature.Dashboard, balance: 0 }, + ]), + ); + expect( + await getCustomerProductEntitlementBalances({ + ctx, + customerProductId, + }), + ).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ feature_id: TestFeature.Messages }), + ]), + ); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: patch customize applies to future customer products and Stripe schedule")}`, + async () => { + const base = products.base({ + id: "create-schedule-patch-future", + items: [ + items.monthlyPrice(), + items.monthlyMessages({ includedUsage: 100 }), + items.prepaid({ + featureId: TestFeature.Words, + price: 10, + billingUnits: 100, + }), + ], + }); + + const { customerId, autumnV2_2, ctx } = await initScenario({ + customerId: "create-schedule-patch-future", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const now = Date.now(); + const response = await autumnV2_2.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: { + remove_items: [ + { + feature_id: TestFeature.Words, + billing_method: BillingMethod.Prepaid, + }, + ], + add_items: [ + itemsV2.prepaidWords({ amount: 7, billingUnits: 100 }), + ], + }, + feature_quantities: [ + { + feature_id: TestFeature.Words, + quantity: 300, + }, + ], + }, + ], + }, + ], + }); + + const futureCustomerProductId = + response.phases[1]!.customer_product_ids[0]!; + const futureCustomerProduct = await ctx.db.query.customerProducts.findFirst( + { + where: eq(customerProducts.id, futureCustomerProductId), + }, + ); + + expect(futureCustomerProduct?.is_custom).toBe(true); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual([20]); + expect( + await getCustomerProductFeaturePriceAmounts({ + ctx, + customerProductId: futureCustomerProductId, + featureId: TestFeature.Words, + }), + ).toEqual([7]); + expect( + await getCustomerProductEntitlementBalances({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual( + expect.arrayContaining([ + { feature_id: TestFeature.Messages, balance: 100 }, + { feature_id: TestFeature.Words, balance: 300 }, + ]), + ); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); + }, +); + test.concurrent( `${chalk.yellowBright("create-schedule: preserves customize.items on created customer products")}`, async () => { diff --git a/server/tests/integration/billing/create-schedule/utils/createScheduleTestHelpers.ts b/server/tests/integration/billing/create-schedule/utils/createScheduleTestHelpers.ts index 9dbb2d9fc..06ec73be5 100644 --- a/server/tests/integration/billing/create-schedule/utils/createScheduleTestHelpers.ts +++ b/server/tests/integration/billing/create-schedule/utils/createScheduleTestHelpers.ts @@ -51,6 +51,40 @@ export const getCustomerProductPriceAmounts = async ({ .filter((amount): amount is number => typeof amount === "number") .sort((a, b) => a - b); +export const getCustomerProductFeaturePriceAmounts = async ({ + ctx, + customerProductId, + featureId, +}: { + ctx: Ctx; + customerProductId: string; + featureId: 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)) + ) + .flatMap((row) => { + const config = row.config; + if ( + !config || + !("feature_id" in config) || + config.feature_id !== featureId || + !("usage_tiers" in config) || + !Array.isArray(config.usage_tiers) + ) { + return []; + } + + return config.usage_tiers + .map((tier) => tier.amount) + .filter((amount): amount is number => typeof amount === "number"); + }) + .sort((a, b) => a - b); + export const getCustomerProductEntitlementBalances = async ({ ctx, customerProductId, 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/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..2f30caee5 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-paid-features.test.ts @@ -0,0 +1,273 @@ +/** + * 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: same-feature usage below the old allowance still synthesizes paid packs. +test.concurrent( + `${chalk.yellowBright("migrations update_plan: prepaid users replacement keeps carried usage quantity")}`, + async () => { + const customerId = "migration-update-paid-prepaid-users"; + const usersUsage = 8; + 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: 10, + 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: 7, + 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 4f1cbcc13..000000000 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-states.test.ts +++ /dev/null @@ -1,566 +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 { - CusProductStatus, - findActiveCustomerProductById, - customerPrices, - customerProducts, - customers, - prices, - type ApiCustomerV3, - type ApiEntityV0, -} 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), - ), - ) - ) - .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); - -// 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/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/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/preview/preview-next-cycle-tax.test.ts b/server/tests/integration/billing/preview/preview-next-cycle-tax.test.ts new file mode 100644 index 000000000..7ecab9d94 --- /dev/null +++ b/server/tests/integration/billing/preview/preview-next-cycle-tax.test.ts @@ -0,0 +1,127 @@ +/** + * TDD tests for next_cycle tax on billing previews (flim-ai report). + * next_cycle.total previously excluded tax even though its docstring says + * "after discounts and tax", so previews disagreed with the renewal invoice. + * + * Red-failure mode (pre-fix): + * - next_cycle.total = post-discount subtotal only + * (34.90 plan, 50% once coupon, 20% VAT -> 17.45 instead of 20.94). + * + * Green-success criteria (post-fix): + * - next_cycle.total includes exclusive tax, mirroring the top-level + * total contract. + */ + +import { expect, test } from "bun:test"; +import type { PreviewUpdateSubscriptionResponse } from "@autumn/shared"; +import { createPercentCoupon } from "@tests/integration/billing/utils/discounts/discountTestUtils.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"; + +// flim parity: 34.90 base, 50% once coupon, 20% exclusive VAT -> 20.94. +test.concurrent( + `${chalk.yellowBright("preview-next-cycle-tax 1: tax_rate_id + once discount -> next_cycle.total includes VAT")}`, + async () => { + const customerId = "preview-next-cycle-tax-disc"; + + const pro = products.base({ + id: "pro", + items: [items.monthlyPrice({ price: 34.9 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const taxRate = await ctx.stripeCli.taxRates.create({ + display_name: "VAT", + percentage: 20, + inclusive: false, + }); + + await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + tax_rate_id: taxRate.id, + }); + + const coupon = await createPercentCoupon({ + stripeCli: ctx.stripeCli, + percentOff: 50, + duration: "once", + }); + + const preview = (await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: pro.id, + discounts: [{ reward_id: coupon.id }], + })) as PreviewUpdateSubscriptionResponse; + + expect(preview.total).toBe(0); + expect(preview.next_cycle, "next_cycle should be defined").toBeDefined(); + const nextCycle = preview.next_cycle!; + + // 34.90 - 50% = 17.45, + 20% VAT = 20.94. + expect(nextCycle.subtotal).toBe(34.9); + expect(nextCycle.total).toBe(20.94); + }, +); + +test.concurrent( + `${chalk.yellowBright("preview-next-cycle-tax 2: tax_rate_id without discount -> plain renewal taxed")}`, + async () => { + const customerId = "preview-next-cycle-tax-plain"; + + const pro = products.base({ + id: "pro", + items: [items.monthlyPrice({ price: 20 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const taxRate = await ctx.stripeCli.taxRates.create({ + display_name: "VAT", + percentage: 10, + inclusive: false, + }); + + await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + tax_rate_id: taxRate.id, + }); + + const coupon = await createPercentCoupon({ + stripeCli: ctx.stripeCli, + percentOff: 50, + duration: "forever", + }); + + const preview = (await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: pro.id, + discounts: [{ reward_id: coupon.id }], + })) as PreviewUpdateSubscriptionResponse; + + expect(preview.next_cycle, "next_cycle should be defined").toBeDefined(); + const nextCycle = preview.next_cycle!; + + // 20 - 50% = 10, + 10% VAT = 11. + expect(nextCycle.subtotal).toBe(20); + expect(nextCycle.total).toBe(11); + }, +); 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/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/discounts/once-discount-next-cycle-preview.test.ts b/server/tests/integration/billing/update-subscription/discounts/once-discount-next-cycle-preview.test.ts new file mode 100644 index 000000000..ac09dfb32 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/discounts/once-discount-next-cycle-preview.test.ts @@ -0,0 +1,122 @@ +/** + * TDD test for: previewUpdateSubscription with a fresh `once`-duration coupon + * via `discounts: [{ reward_id }]` not applying it to next_cycle (flim-ai). + * A discount-only update creates no immediate invoice, so Stripe applies the + * once coupon to the next renewal invoice — the preview should match. + * + * Red-failure mode (current behavior): + * - next_cycle.total stays 34.90 with no discounts; the execution test shows + * the actual renewal invoice IS discounted to 17.45. + * + * Green-success criteria (after fix): + * - next_cycle.total = 17.45 with the discount on the base-price line item. + */ + +import { expect, test } from "bun:test"; +import type { + ApiCustomerV3, + PreviewUpdateSubscriptionResponse, +} from "@autumn/shared"; +import { createPercentCoupon } from "@tests/integration/billing/utils/discounts/discountTestUtils.js"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.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"; + +test.concurrent( + `${chalk.yellowBright("preview-update-once-discount: fresh once coupon with no immediate invoice applies to next_cycle")}`, + async () => { + const customerId = "preview-update-once-disc"; + + const pro = products.base({ + id: "pro", + items: [items.monthlyPrice({ price: 34.9 })], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const coupon = await createPercentCoupon({ + stripeCli: ctx.stripeCli, + percentOff: 50, + duration: "once", + }); + + const preview = (await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: pro.id, + discounts: [{ reward_id: coupon.id }], + })) as PreviewUpdateSubscriptionResponse; + + // Discount-only update: nothing due today. + expect(preview.total).toBe(0); + + expect(preview.next_cycle, "next_cycle should be defined").toBeDefined(); + const nextCycle = preview.next_cycle!; + + expect( + nextCycle.line_items.some((lineItem) => lineItem.discounts.length > 0), + ).toBe(true); + expect(nextCycle.total).toBe(17.45); + }, + 300_000, +); + +// Ground truth: executing the same update and advancing to renewal shows +// Stripe applies the once coupon to the renewal invoice (17.45, not 34.90). +test.concurrent( + `${chalk.yellowBright("preview-update-once-discount: execution ground truth — renewal invoice is discounted")}`, + async () => { + const customerId = "update-once-disc-ground-truth"; + + const pro = products.base({ + id: "pro", + items: [items.monthlyPrice({ price: 34.9 })], + }); + + const { autumnV1, testClockId, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const coupon = await createPercentCoupon({ + stripeCli: ctx.stripeCli, + percentOff: 50, + duration: "once", + }); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + discounts: [{ reward_id: coupon.id }], + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfMonths: 1, + numberOfHours: 2, + waitForSeconds: 30, + }); + + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 17.45, + }); + }, + 300_000, +); 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/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/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/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/setup/v2Features.ts b/server/tests/setup/v2Features.ts index 26b8aa106..56b117301 100644 --- a/server/tests/setup/v2Features.ts +++ b/server/tests/setup/v2Features.ts @@ -4,6 +4,7 @@ dotenv.config(); import { AppEnv, FeatureUsageType } from "@autumn/shared"; import { + constructAiCreditSystem, constructBooleanFeature, constructCreditSystem, constructMeteredFeature, @@ -25,6 +26,12 @@ export enum TestFeature { Action3 = "action3", // single use (pay per use) Credits2 = "credits2", // credit system + + AiCredits = "ai_credits", // AI credit system (models.dev pricing) + AiCredits2 = "ai_credits_2", // second AI credit system (for disambiguation tests) + AiCreditsTiered = "ai_credits_tiered", // AI credit system with global + provider markup tiers + + Orbs = "orbs", // credit system that wraps an AI credit system (1000 orbs per $1) } export const getFeatures = ({ orgId }: { orgId: string }) => ({ @@ -121,4 +128,70 @@ export const getFeatures = ({ orgId }: { orgId: string }) => ({ }, ], }), + [TestFeature.AiCredits]: constructAiCreditSystem({ + featureId: TestFeature.AiCredits, + orgId, + env: AppEnv.Sandbox, + modelMarkups: { + "anthropic/claude-sonnet-4-20250514": { + markup: 0, + }, + "anthropic/claude-3-5-haiku-20241022": { + markup: 20, + }, + "custom/internal-model": { + markup: 0, + input_cost: 5, + output_cost: 15, + }, + "custom/marked-up-model": { + markup: 50, + input_cost: 10, + output_cost: 30, + }, + }, + }), + [TestFeature.AiCredits2]: constructAiCreditSystem({ + featureId: TestFeature.AiCredits2, + orgId, + env: AppEnv.Sandbox, + modelMarkups: { + "anthropic/claude-sonnet-4-20250514": { + markup: 10, + }, + }, + }), + [TestFeature.AiCreditsTiered]: constructAiCreditSystem({ + featureId: TestFeature.AiCreditsTiered, + orgId, + env: AppEnv.Sandbox, + defaultMarkup: 10, + providerMarkups: { + custom: { markup: 30 }, + }, + modelMarkups: { + // Per-model override wins over provider/global. + "custom/override-model": { + markup: 5, + input_cost: 10, + output_cost: 20, + }, + // No markup -> inherits the "custom" provider markup (30%). + "custom/provider-fallback-model": { + input_cost: 10, + output_cost: 20, + }, + }, + }), + [TestFeature.Orbs]: constructCreditSystem({ + featureId: TestFeature.Orbs, + orgId, + env: AppEnv.Sandbox, + schema: [ + { + metered_feature_id: TestFeature.AiCredits, + credit_cost: 1000, // 1000 orbs per $1 of AI usage + }, + ], + }), }); 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 new file mode 100644 index 000000000..81f01e3e4 --- /dev/null +++ b/server/tests/unit/balances/track/handle-track-tokens.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { Hono } from "hono"; +import type { AutumnContext, HonoEnv } from "@/honoUtils/HonoEnv.js"; + +const mockState = { + getTokenTrackParamsCalls: [] as Record[], + runTrackWithRolloutCalls: [] as Record[], + queuedForReplay: false, +}; + +const trackBody = { + customer_id: "cus_123", + entity_id: "ent_123", + feature_id: "ai_credits", + value: 3.5, +}; + +const featureDeductions = [ + { + feature: { id: "ai_credits" }, + deduction: 1, + tokens: { + usage: { + modelName: "openai/gpt-4.1", + inputTokens: 100, + outputTokens: 50, + }, + cost: 3.5, + }, + }, +]; + +mock.module("@/internal/balances/track/utils/getTokenTrackParams.js", () => ({ + getTokenTrackParams: async (args: Record) => { + mockState.getTokenTrackParamsCalls.push(args); + return { body: trackBody, featureDeductions }; + }, +})); + +mock.module("@/internal/balances/track/runTrackWithRollout.js", () => ({ + runTrackWithRollout: async (args: { + ctx: AutumnContext; + body: typeof trackBody; + featureDeductions: typeof featureDeductions; + }) => { + mockState.runTrackWithRolloutCalls.push(args); + if (mockState.queuedForReplay) { + args.ctx.extraLogs.trackQueuedForReplay = true; + } + return { + customer_id: args.body.customer_id, + entity_id: args.body.entity_id, + value: args.body.value, + balance: null, + }; + }, +})); + +import { handleTrackTokens } from "@/internal/balances/handlers/handleTrackTokens.js"; + +const requestBody = { + customer_id: "cus_123", + entity_id: "ent_123", + model_id: "openai/gpt-4.1", + input_tokens: 100, + output_tokens: 50, +}; + +const createApp = ({ ctx }: { ctx: AutumnContext }) => { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("ctx", ctx); + await next(); + }); + app.post("/track_tokens", ...handleTrackTokens); + return app; +}; + +const createCtx = (): AutumnContext => + ({ + features: [], + extraLogs: {}, + scopes: [], + skipCache: false, + }) as unknown as AutumnContext; + +describe("handleTrackTokens", () => { + beforeEach(() => { + mockState.getTokenTrackParamsCalls = []; + mockState.runTrackWithRolloutCalls = []; + mockState.queuedForReplay = false; + }); + + test("tracks converted token usage through the rollout path", async () => { + const ctx = createCtx(); + const response = await createApp({ ctx }).request("/track_tokens", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestBody), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + customer_id: "cus_123", + entity_id: "ent_123", + value: 3.5, + balance: null, + }); + expect(mockState.getTokenTrackParamsCalls).toHaveLength(1); + expect(mockState.getTokenTrackParamsCalls[0]).toMatchObject({ + input: requestBody, + }); + expect(mockState.runTrackWithRolloutCalls).toHaveLength(1); + expect(mockState.runTrackWithRolloutCalls[0]).toMatchObject({ + body: trackBody, + featureDeductions, + }); + }); + + test("returns 202 when rollout fallback queues token tracking for replay", async () => { + mockState.queuedForReplay = true; + const ctx = createCtx(); + const response = await createApp({ ctx }).request("/track_tokens", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestBody), + }); + + expect(response.status).toBe(202); + expect(ctx.extraLogs.trackQueuedForReplay).toBe(true); + expect(mockState.runTrackWithRolloutCalls).toHaveLength(1); + }); +}); 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/create-schedule-params.spec.ts b/server/tests/unit/billing/create-schedule/create-schedule-params.spec.ts index a688a318b..76e9282ad 100644 --- a/server/tests/unit/billing/create-schedule/create-schedule-params.spec.ts +++ b/server/tests/unit/billing/create-schedule/create-schedule-params.spec.ts @@ -54,6 +54,47 @@ describe(chalk.yellowBright("CreateScheduleParamsV0Schema"), () => { ).toThrow(); }); + test("rejects empty customize inputs", () => { + expect(() => + CreateScheduleParamsV0Schema.parse({ + customer_id: "cus_123", + phases: [ + { + starts_at: 1_000, + plans: [ + { + plan_id: "pro", + customize: {}, + }, + ], + }, + ], + }), + ).toThrow("When using customize, at least one of price"); + }); + + test("rejects mixed customize items and patch items", () => { + expect(() => + CreateScheduleParamsV0Schema.parse({ + customer_id: "cus_123", + phases: [ + { + starts_at: 1_000, + plans: [ + { + plan_id: "pro", + customize: { + items: [{ feature_id: "messages" }], + add_items: [{ feature_id: "words" }], + }, + }, + ], + }, + ], + }), + ).toThrow("customize.items (PUT-style) cannot be combined"); + }); + test("preserves subscription_id on parsed plan items", () => { const parsed = CreateScheduleParamsV0Schema.parse({ customer_id: "cus_123", 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/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/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/merge-entity-and-customer-subject-rows.test.ts b/server/tests/unit/customers/merge-entity-and-customer-subject-rows.test.ts new file mode 100644 index 000000000..6d56d9eb9 --- /dev/null +++ b/server/tests/unit/customers/merge-entity-and-customer-subject-rows.test.ts @@ -0,0 +1,432 @@ +import { describe, expect, test } from "bun:test"; +import type { + DbCustomerEntitlement, + DbCustomerPrice, + DbCustomerProduct, + SubjectQueryRow, +} from "@autumn/shared"; +import { + CUSTOMER_PRODUCT_LIMIT, + EXTRA_CUSTOMER_ENTITLEMENT_LIMIT, +} from "@/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.js"; +import { mergeEntityAndCustomerSubjectRows } from "@/internal/customers/repos/getFullSubject/mergeEntityAndCustomerSubjectRows.js"; + +const createCustomerProduct = ({ + id, + internalProductId = `prod_internal_${id}`, + internalCustomerId = "cus_internal_1", + freeTrialId = null, + subscriptionIds = [], +}: { + id: string; + internalProductId?: string; + internalCustomerId?: string; + freeTrialId?: string | null; + subscriptionIds?: string[]; +}) => + ({ + id, + internal_product_id: internalProductId, + internal_customer_id: internalCustomerId, + free_trial_id: freeTrialId, + subscription_ids: subscriptionIds, + }) as DbCustomerProduct; + +const createCustomerEntitlement = ({ + id, + customerProductId, + entitlementId = `ent_${id}`, +}: { + id: string; + customerProductId: string | null; + entitlementId?: string; +}) => + ({ + id, + customer_product_id: customerProductId, + entitlement_id: entitlementId, + }) as DbCustomerEntitlement; + +const createCustomerPrice = ({ + id, + customerProductId, + priceId = `price_${id}`, +}: { + id: string; + customerProductId: string | null; + priceId?: string; +}) => + ({ + id, + customer_product_id: customerProductId, + price_id: priceId, + }) as DbCustomerPrice; + +const createRow = (overrides: Partial = {}): SubjectQueryRow => + ({ + customer: { internal_id: "cus_internal_1" }, + customer_products: [], + customer_entitlements: [], + customer_prices: [], + extra_customer_entitlements: [], + replaceables: [], + rollovers: [], + products: [], + entitlements: [], + prices: [], + free_trials: [], + subscriptions: [], + ...overrides, + }) as SubjectQueryRow; + +describe("mergeEntityAndCustomerSubjectRows", () => { + test("returns entity row unchanged when customer row is missing", () => { + const entityRow = createRow({ + customer_products: [createCustomerProduct({ id: "cp_entity_1" })], + }); + + const merged = mergeEntityAndCustomerSubjectRows({ + entityRow, + customerRow: undefined, + }); + + expect(merged).toBe(entityRow); + }); + + test("drops entity-scoped products belonging to a different customer", () => { + const entityRow = createRow({ + customer_products: [ + createCustomerProduct({ id: "cp_ours" }), + createCustomerProduct({ + id: "cp_other_customer", + internalCustomerId: "cus_internal_other", + }), + ], + }); + const customerRow = createRow(); + + const merged = mergeEntityAndCustomerSubjectRows({ + entityRow, + customerRow, + }); + + expect(merged.customer_products.map((product) => product.id)).toEqual([ + "cp_ours", + ]); + }); + + test("orders entity-scoped rows before customer-level rows", () => { + const entityRow = createRow({ + customer_products: [ + createCustomerProduct({ id: "cp_entity_1" }), + createCustomerProduct({ id: "cp_entity_2" }), + ], + extra_customer_entitlements: [ + createCustomerEntitlement({ + id: "ce_extra_entity", + customerProductId: null, + }), + ], + }); + const customerRow = createRow({ + customer_products: [createCustomerProduct({ id: "cp_customer_1" })], + extra_customer_entitlements: [ + createCustomerEntitlement({ + id: "ce_extra_customer", + customerProductId: null, + }), + ], + }); + + const merged = mergeEntityAndCustomerSubjectRows({ + entityRow, + customerRow, + }); + + expect(merged.customer_products.map((product) => product.id)).toEqual([ + "cp_entity_1", + "cp_entity_2", + "cp_customer_1", + ]); + expect( + merged.extra_customer_entitlements.map((entitlement) => entitlement.id), + ).toEqual(["ce_extra_entity", "ce_extra_customer"]); + }); + + test("keeps customer fields from the entity row", () => { + const entityRow = createRow({ + entity: { internal_id: "entity_internal_1" } as SubjectQueryRow["entity"], + }); + const customerRow = createRow(); + + const merged = mergeEntityAndCustomerSubjectRows({ + entityRow, + customerRow, + }); + + expect(merged.customer).toBe(entityRow.customer); + expect(merged.entity).toBe(entityRow.entity); + expect(merged.invoices).toBeUndefined(); + expect(merged.entity_aggregations).toBeUndefined(); + }); + + test("cap truncation drops customer-level products and all their dependent rows", () => { + const entityProducts = Array.from( + { length: CUSTOMER_PRODUCT_LIMIT - 1 }, + (_, index) => createCustomerProduct({ id: `cp_entity_${index}` }), + ); + const keptProduct = createCustomerProduct({ + id: "cp_customer_kept", + freeTrialId: "ft_kept", + subscriptionIds: ["sub_kept"], + }); + const droppedProduct = createCustomerProduct({ + id: "cp_customer_dropped", + freeTrialId: "ft_dropped", + subscriptionIds: ["sub_dropped"], + }); + + const entityRow = createRow({ customer_products: entityProducts }); + const customerRow = createRow({ + customer_products: [keptProduct, droppedProduct], + customer_entitlements: [ + createCustomerEntitlement({ + id: "ce_kept", + customerProductId: keptProduct.id, + entitlementId: "ent_kept", + }), + createCustomerEntitlement({ + id: "ce_dropped", + customerProductId: droppedProduct.id, + entitlementId: "ent_dropped", + }), + ], + customer_prices: [ + createCustomerPrice({ + id: "cpr_kept", + customerProductId: keptProduct.id, + priceId: "price_kept", + }), + createCustomerPrice({ + id: "cpr_dropped", + customerProductId: droppedProduct.id, + priceId: "price_dropped", + }), + ], + rollovers: [ + { id: "ro_kept", cus_ent_id: "ce_kept" }, + { id: "ro_dropped", cus_ent_id: "ce_dropped" }, + ] as SubjectQueryRow["rollovers"], + replaceables: [ + { id: "rep_kept", cus_ent_id: "ce_kept" }, + { id: "rep_dropped", cus_ent_id: "ce_dropped" }, + ] as SubjectQueryRow["replaceables"], + products: [ + { internal_id: keptProduct.internal_product_id }, + { internal_id: droppedProduct.internal_product_id }, + ] as SubjectQueryRow["products"], + entitlements: [ + { id: "ent_kept" }, + { id: "ent_dropped" }, + ] as SubjectQueryRow["entitlements"], + prices: [ + { id: "price_kept" }, + { id: "price_dropped" }, + ] as SubjectQueryRow["prices"], + free_trials: [ + { id: "ft_kept" }, + { id: "ft_dropped" }, + ] as SubjectQueryRow["free_trials"], + subscriptions: [ + { stripe_id: "sub_kept" }, + { stripe_id: "sub_dropped" }, + ] as SubjectQueryRow["subscriptions"], + }); + + const merged = mergeEntityAndCustomerSubjectRows({ + entityRow, + customerRow, + }); + + expect(merged.customer_products).toHaveLength(CUSTOMER_PRODUCT_LIMIT); + expect( + merged.customer_products[merged.customer_products.length - 1]?.id, + ).toBe(keptProduct.id); + expect(merged.customer_entitlements.map((row) => row.id)).toEqual([ + "ce_kept", + ]); + expect(merged.customer_prices.map((row) => row.id)).toEqual(["cpr_kept"]); + expect(merged.rollovers.map((row) => row.id)).toEqual(["ro_kept"]); + expect(merged.replaceables.map((row) => row.id)).toEqual(["rep_kept"]); + expect(merged.products.map((row) => row.internal_id)).toContain( + keptProduct.internal_product_id, + ); + expect(merged.products.map((row) => row.internal_id)).not.toContain( + droppedProduct.internal_product_id, + ); + expect(merged.entitlements.map((row) => row.id)).toEqual(["ent_kept"]); + expect(merged.prices.map((row) => row.id)).toEqual(["price_kept"]); + expect(merged.free_trials.map((row) => row.id)).toEqual(["ft_kept"]); + expect(merged.subscriptions.map((row) => row.stripe_id)).toEqual([ + "sub_kept", + ]); + }); + + test("extras cap truncation drops the dropped extras' rollovers and entitlement refs", () => { + const entityExtras = Array.from( + { length: EXTRA_CUSTOMER_ENTITLEMENT_LIMIT }, + (_, index) => + createCustomerEntitlement({ + id: `ce_extra_entity_${index}`, + customerProductId: null, + entitlementId: `ent_extra_entity_${index}`, + }), + ); + const droppedExtra = createCustomerEntitlement({ + id: "ce_extra_customer_dropped", + customerProductId: null, + entitlementId: "ent_extra_dropped", + }); + + const entityRow = createRow({ + extra_customer_entitlements: entityExtras, + entitlements: entityExtras.map( + (extra) => + ({ + id: extra.entitlement_id, + }) as SubjectQueryRow["entitlements"][number], + ), + }); + const customerRow = createRow({ + extra_customer_entitlements: [droppedExtra], + rollovers: [ + { id: "ro_dropped", cus_ent_id: droppedExtra.id }, + ] as SubjectQueryRow["rollovers"], + entitlements: [ + { id: droppedExtra.entitlement_id }, + ] as SubjectQueryRow["entitlements"], + }); + + const merged = mergeEntityAndCustomerSubjectRows({ + entityRow, + customerRow, + }); + + expect(merged.extra_customer_entitlements).toHaveLength( + EXTRA_CUSTOMER_ENTITLEMENT_LIMIT, + ); + expect( + merged.extra_customer_entitlements.map((row) => row.id), + ).not.toContain(droppedExtra.id); + expect(merged.rollovers).toHaveLength(0); + expect(merged.entitlements.map((row) => row.id)).not.toContain( + droppedExtra.entitlement_id, + ); + }); + + test("dedupes shared catalog rows and subscriptions across both rows", () => { + const entityProduct = createCustomerProduct({ + id: "cp_entity_1", + internalProductId: "prod_shared", + freeTrialId: "ft_shared", + subscriptionIds: ["sub_shared"], + }); + const customerProduct = createCustomerProduct({ + id: "cp_customer_1", + internalProductId: "prod_shared", + freeTrialId: "ft_shared", + subscriptionIds: ["sub_shared"], + }); + const sharedCatalog = { + products: [{ internal_id: "prod_shared" }] as SubjectQueryRow["products"], + prices: [{ id: "price_shared" }] as SubjectQueryRow["prices"], + entitlements: [{ id: "ent_shared" }] as SubjectQueryRow["entitlements"], + free_trials: [{ id: "ft_shared" }] as SubjectQueryRow["free_trials"], + subscriptions: [ + { stripe_id: "sub_shared" }, + ] as SubjectQueryRow["subscriptions"], + }; + + const entityRow = createRow({ + customer_products: [entityProduct], + customer_entitlements: [ + createCustomerEntitlement({ + id: "ce_entity", + customerProductId: entityProduct.id, + entitlementId: "ent_shared", + }), + ], + customer_prices: [ + createCustomerPrice({ + id: "cpr_entity", + customerProductId: entityProduct.id, + priceId: "price_shared", + }), + ], + ...sharedCatalog, + }); + const customerRow = createRow({ + customer_products: [customerProduct], + customer_entitlements: [ + createCustomerEntitlement({ + id: "ce_customer", + customerProductId: customerProduct.id, + entitlementId: "ent_shared", + }), + ], + customer_prices: [ + createCustomerPrice({ + id: "cpr_customer", + customerProductId: customerProduct.id, + priceId: "price_shared", + }), + ], + ...sharedCatalog, + }); + + const merged = mergeEntityAndCustomerSubjectRows({ + entityRow, + customerRow, + }); + + expect(merged.products).toHaveLength(1); + expect(merged.prices).toHaveLength(1); + expect(merged.entitlements).toHaveLength(1); + expect(merged.free_trials).toHaveLength(1); + expect(merged.subscriptions).toHaveLength(1); + expect(merged.customer_entitlements.map((row) => row.id)).toEqual([ + "ce_entity", + "ce_customer", + ]); + }); + + test("sorts merged catalog rows by id to mirror DISTINCT ON ordering", () => { + const entityProduct = createCustomerProduct({ + id: "cp_entity_1", + internalProductId: "prod_b", + }); + const customerProduct = createCustomerProduct({ + id: "cp_customer_1", + internalProductId: "prod_a", + }); + + const entityRow = createRow({ + customer_products: [entityProduct], + products: [{ internal_id: "prod_b" }] as SubjectQueryRow["products"], + }); + const customerRow = createRow({ + customer_products: [customerProduct], + products: [{ internal_id: "prod_a" }] as SubjectQueryRow["products"], + }); + + const merged = mergeEntityAndCustomerSubjectRows({ + entityRow, + customerRow, + }); + + expect(merged.products.map((row) => row.internal_id)).toEqual([ + "prod_a", + "prod_b", + ]); + }); +}); 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..5eb2d6f31 --- /dev/null +++ b/server/tests/unit/features/get-credit-cost.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + ErrCode, + type Feature, + FeatureType, + FeatureUsageType, +} from "@autumn/shared"; + +mock.module("@/internal/features/utils/getModelPricing.js", () => ({ + getModelsDevPricing: async () => ({}), +})); + +const { getModelCreditCost, getModelCreditCostBreakdown } = await import( + "@/internal/features/aiCreditSystemUtils.js" +); +const { getCreditCost } = await import( + "@/internal/features/creditSystemUtils.js" +); + +// custom/* models price from model_markups; pricing data is mocked empty. +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/full-subject-cache/full-subject-aggregate-balance.test.ts b/server/tests/unit/full-subject-cache/full-subject-aggregate-balance.test.ts index 17951a41a..1d9215506 100644 --- a/server/tests/unit/full-subject-cache/full-subject-aggregate-balance.test.ts +++ b/server/tests/unit/full-subject-cache/full-subject-aggregate-balance.test.ts @@ -18,6 +18,7 @@ describe("fullSubject aggregate balance", () => { balance: 180, adjustment: 10, additional_balance: 0, + next_reset_at: 1234567890, rollover_balance: 0, rollover_usage: 0, unlimited: false, @@ -76,5 +77,6 @@ describe("fullSubject aggregate balance", () => { expect(merged.granted).toBe(310); expect(merged.remaining).toBe(200); expect(merged.usage).toBe(110); + expect(merged.next_reset_at).toBe(1234567890); }); }); 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/rate-limits/org-aggregate-config.test.ts b/server/tests/unit/rate-limits/org-aggregate-config.test.ts new file mode 100644 index 000000000..922739dc9 --- /dev/null +++ b/server/tests/unit/rate-limits/org-aggregate-config.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { + getOrgAggregateType, + RATE_LIMIT_CONFIGS, + RateLimitScope, + RateLimitType, +} from "@/internal/misc/rateLimiter/rateLimitConfigs.js"; + +describe("org aggregate rate limits", () => { + test("high-volume per-customer types map to an org aggregate", () => { + expect(getOrgAggregateType(RateLimitType.Track)).toBe( + RateLimitType.TrackOrg, + ); + expect(getOrgAggregateType(RateLimitType.Check)).toBe( + RateLimitType.CheckOrg, + ); + expect(getOrgAggregateType(RateLimitType.CustomerEntitiesGet)).toBe( + RateLimitType.EntitiesGetOrg, + ); + }); + + test("types without an aggregate return undefined", () => { + expect(getOrgAggregateType(RateLimitType.General)).toBeUndefined(); + expect(getOrgAggregateType(RateLimitType.Attach)).toBeUndefined(); + expect(getOrgAggregateType(RateLimitType.TrackOrg)).toBeUndefined(); + }); + + test("aggregate configs are org-scoped, redis-backed, 60s windows", () => { + const aggregates = [ + RateLimitType.TrackOrg, + RateLimitType.CheckOrg, + RateLimitType.EntitiesGetOrg, + ]; + for (const type of aggregates) { + const config = RATE_LIMIT_CONFIGS[type]; + expect(config.scope).toBe(RateLimitScope.Org); + expect(config.notInRedis).toBe(false); + expect(config.windowMs).toBe(60_000); + expect(config.limit).toBeGreaterThan(0); + } + }); + + test("check/track aggregates degrade (fail open) instead of rejecting", () => { + expect(RATE_LIMIT_CONFIGS[RateLimitType.CheckOrg].overLimit).toBe( + "degrade", + ); + expect(RATE_LIMIT_CONFIGS[RateLimitType.TrackOrg].overLimit).toBe( + "degrade", + ); + expect( + RATE_LIMIT_CONFIGS[RateLimitType.EntitiesGetOrg].overLimit, + ).toBeUndefined(); + }); +}); 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/db/entitlements.ts b/server/tests/utils/fixtures/db/entitlements.ts index f8c05e8e2..7bc7dcf0c 100644 --- a/server/tests/utils/fixtures/db/entitlements.ts +++ b/server/tests/utils/fixtures/db/entitlements.ts @@ -2,6 +2,7 @@ import { AllowanceType, type EntInterval, FeatureType, + type ModelMarkups, type RolloverConfig, } from "@autumn/shared"; import { features } from "./features"; @@ -21,6 +22,7 @@ const create = ({ intervalCount = 1, entityFeatureId = null, rollover = null, + modelMarkups = null, }: { id?: string; featureId: string; @@ -33,6 +35,7 @@ const create = ({ intervalCount?: number; entityFeatureId?: string | null; rollover?: RolloverConfig | null; + modelMarkups?: ModelMarkups; }) => ({ id: id ?? `ent_${featureId}_${crypto.randomUUID().slice(0, 8)}`, created_at: Date.now(), @@ -54,6 +57,7 @@ const create = ({ name: featureName, type: featureType, config: featureConfig, + modelMarkups: modelMarkups ?? null, }), }); diff --git a/server/tests/utils/fixtures/db/features.ts b/server/tests/utils/fixtures/db/features.ts index 807712d42..05dcc2df6 100644 --- a/server/tests/utils/fixtures/db/features.ts +++ b/server/tests/utils/fixtures/db/features.ts @@ -1,4 +1,4 @@ -import { AppEnv, FeatureType } from "@autumn/shared"; +import { AppEnv, FeatureType, type ModelMarkups } from "@autumn/shared"; /** * Create a feature fixture @@ -9,12 +9,14 @@ const create = ({ name, type = FeatureType.Metered, config = {}, + modelMarkups = null, }: { id: string; internalId?: string; name: string; type?: FeatureType; config?: Record; + modelMarkups?: ModelMarkups; }) => ({ internal_id: internalId ?? `internal_${id}`, org_id: "org_test", @@ -27,6 +29,7 @@ const create = ({ display: null, archived: false, event_names: [], + model_markups: modelMarkups ?? null, }); // ═══════════════════════════════════════════════════════════════════ 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/shared/api/balances/track/trackTokensParams.ts b/shared/api/balances/track/trackTokensParams.ts new file mode 100644 index 000000000..a8ebef2c5 --- /dev/null +++ b/shared/api/balances/track/trackTokensParams.ts @@ -0,0 +1,63 @@ +import { z } from "zod/v4"; +import { CustomerDataSchema } from "../../common/customerData"; +import { EntityDataSchema } from "../../common/entityData"; + +export const TrackTokensParamsSchema = z.object({ + customer_id: z.string().meta({ + description: "The ID of the customer.", + }), + entity_id: z.string().optional().meta({ + description: "The ID of the entity for entity-scoped balances.", + }), + feature_id: z.string().optional().meta({ + description: + "The ID of the AI credit system feature. Auto-detected from the customer's entitlements if omitted — only required when a customer has multiple AI credit systems.", + }), + model_id: z.string().meta({ + description: + "The AI model as '/' (e.g. 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). The provider is the first path segment and must match a provider + model key in models.dev.", + }), + input_tokens: z.number().int().nonnegative().meta({ + description: + "Number of non-cached text input tokens consumed. Exclusive of cache and audio token pools.", + }), + output_tokens: z.number().int().nonnegative().meta({ + description: + "Number of text output tokens consumed. Exclusive of the reasoning and audio output pools.", + }), + cache_read_tokens: z.number().int().nonnegative().optional().meta({ + description: "Number of cached input tokens read.", + }), + cache_write_tokens: z.number().int().nonnegative().optional().meta({ + description: "Number of input tokens written to the cache.", + }), + audio_input_tokens: z.number().int().nonnegative().optional().meta({ + description: "Number of audio input tokens consumed.", + }), + audio_output_tokens: z.number().int().nonnegative().optional().meta({ + description: "Number of audio output tokens generated.", + }), + reasoning_tokens: z.number().int().nonnegative().optional().meta({ + description: "Number of reasoning tokens generated.", + }), + properties: z.record(z.string(), z.any()).optional().meta({ + description: "Additional properties to attach to this usage event.", + }), + idempotency_key: z.string().optional().meta({ + internal: true, + }), + overage_behavior: z.enum(["cap", "reject"]).optional().meta({ + internal: true, + }), + customer_data: CustomerDataSchema.optional().meta({ + internal: true, + }), + entity_data: EntityDataSchema.optional().meta({ + internal: true, + }), + skip_event: z.boolean().optional().meta({ + internal: true, + }), +}); + +export type TrackTokensParams = z.infer; 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..42747148a 100644 --- a/shared/api/billing/common/customizePlan/customizePlanV1.ts +++ b/shared/api/billing/common/customizePlan/customizePlanV1.ts @@ -2,8 +2,10 @@ 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"; +/** Deprecated: use remove_items and add_items to replace plan items. */ export const UpdatePlanItemParamsV1Schema = z .object({ filter: PlanItemFilterSchema.meta({ @@ -14,24 +16,90 @@ export const UpdatePlanItemParamsV1Schema = z description: "Override the matched item's included usage / allowance. Existing usage carries forward.", }), + 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: - "Patch an existing plan item in place. Phase 1 supports only `included`.", + "Deprecated. Use remove_items and add_items to replace plan items.", + deprecated: true, }); -export type UpdatePlanItemParamsV1 = z.infer; +export type UpdatePlanItemParamsV1 = z.infer< + typeof UpdatePlanItemParamsV1Schema +>; -export const CustomizePlanV1Schema = z - .object({ +type CustomizePlanRefinementData = { + price?: unknown; + items?: unknown; + add_items?: unknown; + remove_items?: unknown; + update_items?: unknown; + free_trial?: unknown; +}; + +export const refineCustomizePlanV1Schema = < + TSchema extends z.ZodType, +>( + schema: TSchema, + { + includeFreeTrial = true, + includeUpdateItems = true, + }: { includeFreeTrial?: boolean; includeUpdateItems?: boolean } = {}, +) => + schema + .refine( + (data) => + data.items !== undefined || + data.price !== undefined || + (includeFreeTrial && data.free_trial !== undefined) || + data.add_items !== undefined || + data.remove_items !== undefined || + (includeUpdateItems && data.update_items !== undefined), + { + message: `When using customize, at least one of ${[ + "price", + "items", + "add_items", + "remove_items", + includeUpdateItems ? "deprecated update_items" : null, + includeFreeTrial ? "free_trial" : null, + ] + .filter(Boolean) + .join(", ")} must be provided`, + }, + ) + .refine( + (data) => + !( + data.items !== undefined && + (data.add_items !== undefined || + data.remove_items !== undefined || + (includeUpdateItems && data.update_items !== undefined)) + ), + { + message: `customize.items (PUT-style) cannot be combined with ${[ + "add_items", + "remove_items", + includeUpdateItems ? "deprecated update_items" : null, + ] + .filter(Boolean) + .join(" / ")} (PATCH-style); pick one approach`, + }, + ); + +export const CustomizePlanV1Schema = refineCustomizePlanV1Schema( + z.object({ price: BasePriceParamsSchema.nullable().optional().meta({ 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.", + "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.", @@ -41,45 +109,20 @@ export const CustomizePlanV1Schema = z }), update_items: z.array(UpdatePlanItemParamsV1Schema).optional().meta({ description: - "Patch existing matched plan items. Runs before add_items, after remove_items.", + "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.", }), - }) - .refine( - (data) => - data.items !== undefined || - data.price !== undefined || - data.free_trial !== undefined || - 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", - }, - ) - .refine( - (data) => - !( - data.items !== undefined && - (data.add_items !== undefined || - 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", - }, - ) - .meta({ - title: "CustomizePlan", - description: - "Customize a plan by overriding its price, items, free trial, or a combination.", - }); + }), +).meta({ + title: "CustomizePlan", + description: + "Customize a plan by overriding its price, items, free trial, or a combination.", +}); export type CustomizePlanV1 = z.infer; diff --git a/shared/api/billing/createSchedule/createScheduleParamsV0.ts b/shared/api/billing/createSchedule/createScheduleParamsV0.ts index f17be2b70..aa36c4425 100644 --- a/shared/api/billing/createSchedule/createScheduleParamsV0.ts +++ b/shared/api/billing/createSchedule/createScheduleParamsV0.ts @@ -5,36 +5,20 @@ import { z } from "zod/v4"; import { AttachDiscountSchema } from "../attachV2/attachDiscount"; import { BillingBehaviorSchema } from "../common/billingBehavior"; import { BillingCycleAnchorSchema } from "../common/billingCycleAnchor"; -import { CustomizePlanV1Schema } from "../common/customizePlan/customizePlanV1"; +import { + CustomizePlanV1Schema, + refineCustomizePlanV1Schema, +} from "../common/customizePlan/customizePlanV1"; // update_items is internal / not prod-ready — omit it from the schedule customize // surface so the agent never uses it. -const CreateScheduleCustomizePlanSchema = CustomizePlanV1Schema.omit({ - free_trial: true, - update_items: true, -}) - .refine( - (data) => - data.items !== undefined || - data.price !== undefined || - data.add_items !== undefined || - data.remove_items !== undefined, - { - message: - "When using customize, at least one of price, items, add_items, or remove_items must be provided", - }, - ) - .refine( - (data) => - !( - data.items !== undefined && - (data.add_items !== undefined || data.remove_items !== undefined) - ), - { - message: - "customize.items (PUT-style) cannot be combined with add_items / remove_items (PATCH-style); pick one approach", - }, - ); +const CreateScheduleCustomizePlanSchema = refineCustomizePlanV1Schema( + CustomizePlanV1Schema.omit({ + free_trial: true, + update_items: true, + }), + { includeFreeTrial: false, includeUpdateItems: false }, +); export const CreateSchedulePlanSchema = z.object({ plan_id: z.string().meta({ diff --git a/shared/api/common/paginationConfigs.ts b/shared/api/common/paginationConfigs.ts index 1f0f292e6..e4b0c37b2 100644 --- a/shared/api/common/paginationConfigs.ts +++ b/shared/api/common/paginationConfigs.ts @@ -19,7 +19,7 @@ export const PAGINATION_CONFIGS: Record = { }, [PaginationType.ListEntities]: { defaultLimit: PaginationDefaults.DefaultLimit, - maxLimit: PaginationDefaults.MaxLimit, + maxLimit: PaginationDefaults.SchemaHardCeiling, }, [PaginationType.SearchCustomers]: { defaultLimit: PaginationDefaults.DefaultLimit, @@ -30,5 +30,3 @@ export const PAGINATION_CONFIGS: Record = { maxLimit: PaginationDefaults.MaxLimit, }, }; - - 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/features/apiFeatureV1.ts b/shared/api/features/apiFeatureV1.ts index a5ebac6c1..0f63a62c4 100644 --- a/shared/api/features/apiFeatureV1.ts +++ b/shared/api/features/apiFeatureV1.ts @@ -1,4 +1,8 @@ import { z } from "zod/v4"; +import { + ModelMarkupsSchema, + ProviderMarkupsSchema, +} from "../../models/featureModels/featureConfig/creditConfig"; import { FeatureType } from "../../models/featureModels/featureEnums"; export const ApiFeatureV1Schema = z.object({ @@ -12,7 +16,7 @@ export const ApiFeatureV1Schema = z.object({ }), type: z.enum(FeatureType).meta({ description: - "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.", + "Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.", }), consumable: z.boolean().meta({ @@ -42,6 +46,20 @@ export const ApiFeatureV1Schema = z.object({ "For credit_system features: maps metered features to their credit costs.", }), + model_markups: ModelMarkupsSchema.optional().meta({ + description: "Per-model markup overrides for AI credit systems.", + }), + + default_markup: z.number().min(-100).optional().meta({ + description: + "Default percentage markup for AI credit systems. Use -100 to make usage free.", + }), + + provider_markups: ProviderMarkupsSchema.optional().meta({ + description: + "Per-provider default markup percentages for AI credit systems.", + }), + display: z .object({ singular: z.string().nullish().meta({ diff --git a/shared/api/features/changes/V1.2_FeatureChange.ts b/shared/api/features/changes/V1.2_FeatureChange.ts index e08a1e2d1..5bfc72148 100644 --- a/shared/api/features/changes/V1.2_FeatureChange.ts +++ b/shared/api/features/changes/V1.2_FeatureChange.ts @@ -4,6 +4,7 @@ import { defineVersionChange, } from "@api/versionUtils/versionChangeUtils/VersionChange.js"; import { FeatureType } from "@models/featureModels/featureEnums.js"; +import { isAiCreditSystem } from "@utils/featureUtils/classifyFeature/isAiCreditSystem"; import type { z } from "zod/v4"; import { ApiFeatureV1Schema } from "../apiFeatureV1.js"; import { @@ -63,13 +64,13 @@ export const V1_2_FeatureChange = defineVersionChange({ v0Type = ApiFeatureType.Boolean; } else if (input.type === FeatureType.CreditSystem) { v0Type = ApiFeatureType.CreditSystem; + } else if (isAiCreditSystem(input.type)) { + v0Type = ApiFeatureType.AiCreditSystem; } else if (input.type === FeatureType.Metered) { - // Use consumable flag to determine single_use vs continuous_use v0Type = input.consumable ? ApiFeatureType.SingleUsage : ApiFeatureType.ContinuousUse; } else { - // Fallback (should never happen) v0Type = ApiFeatureType.Boolean; } @@ -83,7 +84,10 @@ export const V1_2_FeatureChange = defineVersionChange({ plural: input.display.plural || "", } : null, - credit_schema: input.credit_schema || null, + credit_schema: input.credit_schema?.map((item) => ({ + metered_feature_id: item.metered_feature_id, + credit_cost: item.credit_cost, + })) || null, archived: input.archived, } satisfies z.infer; }, diff --git a/shared/api/features/crud/common/baseFeatureParamsV1.ts b/shared/api/features/crud/common/baseFeatureParamsV1.ts index e3aa3fcd5..7394be049 100644 --- a/shared/api/features/crud/common/baseFeatureParamsV1.ts +++ b/shared/api/features/crud/common/baseFeatureParamsV1.ts @@ -1,4 +1,8 @@ import { z } from "zod/v4"; +import { + ModelMarkupsSchema, + ProviderMarkupsSchema, +} from "../../../../models/featureModels/featureConfig/creditConfig"; import { FeatureType } from "../../../../models/featureModels/featureEnums"; import { idRegex } from "../../../../utils/utils"; @@ -43,8 +47,23 @@ export const BaseFeatureV1ParamsSchema = z.object({ .optional() .meta({ description: - "A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features.", + "A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead.", }), + model_markups: ModelMarkupsSchema.optional().meta({ + description: + "Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration.", + }), + + default_markup: z.number().min(-100).optional().meta({ + description: + "Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free.", + }), + + provider_markups: ProviderMarkupsSchema.optional().meta({ + description: + "Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id.", + }), + event_names: z.array(z.string()).optional(), }); diff --git a/shared/api/features/prevVersions/apiFeatureV0.ts b/shared/api/features/prevVersions/apiFeatureV0.ts index 1ac487994..cf383d811 100644 --- a/shared/api/features/prevVersions/apiFeatureV0.ts +++ b/shared/api/features/prevVersions/apiFeatureV0.ts @@ -7,6 +7,7 @@ export enum ApiFeatureType { SingleUsage = "single_use", ContinuousUse = "continuous_use", CreditSystem = "credit_system", + AiCreditSystem = "ai_credit_system", } export const FEATURE_EXAMPLE = { 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/models.ts b/shared/api/models.ts index 23271c21c..289bd8d40 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -47,6 +47,7 @@ export * from "./balances/index.js"; export * from "./balances/prevVersions/legacyUpdateBalanceModels.js"; export * from "./balances/track/prevVersions/trackResponseV1.js"; export * from "./balances/track/trackParams.js"; +export * from "./balances/track/trackTokensParams.js"; export * from "./balances/track/trackResponseV2.js"; export * from "./balances/track/trackResponseV3.js"; export * from "./balances/update/updateBalanceParams.js"; 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/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_shocking_wong.sql b/shared/drizzle/0002_shocking_wong.sql new file mode 100644 index 000000000..be0692e60 --- /dev/null +++ b/shared/drizzle/0002_shocking_wong.sql @@ -0,0 +1 @@ +ALTER TABLE "features" ADD COLUMN "model_markups" jsonb DEFAULT null; \ 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..269375952 --- /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/0011_easy_spot.sql b/shared/drizzle/0011_easy_spot.sql new file mode 100644 index 000000000..6f6d9a5dc --- /dev/null +++ b/shared/drizzle/0011_easy_spot.sql @@ -0,0 +1 @@ +CREATE INDEX CONCURRENTLY "idx_entities_customer_created_at" ON "entities" USING btree ("internal_customer_id","created_at" DESC,"id" DESC); diff --git a/shared/drizzle/0012_low_moonstone.sql b/shared/drizzle/0012_low_moonstone.sql new file mode 100644 index 000000000..81192f1de --- /dev/null +++ b/shared/drizzle/0012_low_moonstone.sql @@ -0,0 +1 @@ +ALTER TABLE "features" ADD COLUMN "model_markups" jsonb DEFAULT null;--> statement-breakpoint \ No newline at end of file diff --git a/shared/drizzle/0011_hesitant_cammi.sql b/shared/drizzle/0013_third_darkhawk.sql similarity index 100% rename from shared/drizzle/0011_hesitant_cammi.sql rename to shared/drizzle/0013_third_darkhawk.sql diff --git a/shared/drizzle/meta/0007_snapshot.json b/shared/drizzle/meta/0007_snapshot.json index 0058eb623..bea55d4a2 100644 --- a/shared/drizzle/meta/0007_snapshot.json +++ b/shared/drizzle/meta/0007_snapshot.json @@ -7390,4 +7390,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/shared/drizzle/meta/0011_snapshot.json b/shared/drizzle/meta/0011_snapshot.json index 078f9a82a..363b7744e 100644 --- a/shared/drizzle/meta/0011_snapshot.json +++ b/shared/drizzle/meta/0011_snapshot.json @@ -1,5 +1,5 @@ { - "id": "d2c9ed21-2e65-4206-829d-206afa60d152", + "id": "42afdc3e-154a-493a-bb50-7ae1bfe59831", "prevId": "c5275384-0822-47e9-b14d-d23cd14d42cc", "version": "7", "dialect": "postgresql", @@ -1189,163 +1189,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "leaf.cma_memory": { - "name": "cma_memory", - "schema": "leaf", - "columns": { - "org_id": { - "name": "org_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "env": { - "name": "env", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "memory_store_id": { - "name": "memory_store_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "numeric", - "primaryKey": false, - "notNull": true, - "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "cma_memory_org_id_env_pk": { - "name": "cma_memory_org_id_env_pk", - "columns": [ - "org_id", - "env" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "leaf.cma_sessions": { - "name": "cma_sessions", - "schema": "leaf", - "columns": { - "org_id": { - "name": "org_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "env": { - "name": "env", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "thread_key": { - "name": "thread_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "session_id": { - "name": "session_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "braintrust_parent": { - "name": "braintrust_parent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "numeric", - "primaryKey": false, - "notNull": true, - "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "cma_sessions_org_id_env_thread_key_pk": { - "name": "cma_sessions_org_id_env_thread_key_pk", - "columns": [ - "org_id", - "env", - "thread_key" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "leaf.cma_vaults": { - "name": "cma_vaults", - "schema": "leaf", - "columns": { - "org_id": { - "name": "org_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "env": { - "name": "env", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "vault_id": { - "name": "vault_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "credential_id": { - "name": "credential_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "numeric", - "primaryKey": false, - "notNull": true, - "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "cma_vaults_org_id_env_pk": { - "name": "cma_vaults_org_id_env_pk", - "columns": [ - "org_id", - "env" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.customer_entitlements": { "name": "customer_entitlements", "schema": "", @@ -2742,6 +2585,33 @@ "concurrently": false, "method": "btree", "with": {} + }, + "idx_entities_customer_created_at": { + "name": "idx_entities_customer_created_at", + "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": { @@ -7746,9 +7616,7 @@ } }, "enums": {}, - "schemas": { - "leaf": "leaf" - }, + "schemas": {}, "sequences": {}, "roles": {}, "policies": {}, @@ -7758,4 +7626,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/shared/drizzle/meta/0012_snapshot.json b/shared/drizzle/meta/0012_snapshot.json new file mode 100644 index 000000000..44d6b80c8 --- /dev/null +++ b/shared/drizzle/meta/0012_snapshot.json @@ -0,0 +1,7643 @@ +{ + "id": "0dfbd2e0-13b8-4228-b7d4-21f02c2ba4c4", + "prevId": "42afdc3e-154a-493a-bb50-7ae1bfe59831", + "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": {} + }, + "idx_entities_customer_created_at": { + "name": "idx_entities_customer_created_at", + "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": { + "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": "'{}'" + }, + "model_markups": { + "name": "model_markups", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + } + }, + "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 + }, + "archived": { + "name": "archived", + "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/0013_snapshot.json b/shared/drizzle/meta/0013_snapshot.json new file mode 100644 index 000000000..f11f43608 --- /dev/null +++ b/shared/drizzle/meta/0013_snapshot.json @@ -0,0 +1,7802 @@ +{ + "id": "fc2ee520-88f5-4204-9013-e54d6472effa", + "prevId": "0dfbd2e0-13b8-4228-b7d4-21f02c2ba4c4", + "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 + }, + "leaf.cma_memory": { + "name": "cma_memory", + "schema": "leaf", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "memory_store_id": { + "name": "memory_store_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "cma_memory_org_id_env_pk": { + "name": "cma_memory_org_id_env_pk", + "columns": [ + "org_id", + "env" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "leaf.cma_sessions": { + "name": "cma_sessions", + "schema": "leaf", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_key": { + "name": "thread_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "braintrust_parent": { + "name": "braintrust_parent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "cma_sessions_org_id_env_thread_key_pk": { + "name": "cma_sessions_org_id_env_thread_key_pk", + "columns": [ + "org_id", + "env", + "thread_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "leaf.cma_vaults": { + "name": "cma_vaults", + "schema": "leaf", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vault_id": { + "name": "vault_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "cma_vaults_org_id_env_pk": { + "name": "cma_vaults_org_id_env_pk", + "columns": [ + "org_id", + "env" + ] + } + }, + "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": {} + }, + "idx_entities_customer_created_at": { + "name": "idx_entities_customer_created_at", + "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": { + "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": "'{}'" + }, + "model_markups": { + "name": "model_markups", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + } + }, + "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 + }, + "archived": { + "name": "archived", + "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": { + "leaf": "leaf" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/shared/drizzle/meta/_journal.json b/shared/drizzle/meta/_journal.json index 29e673879..c7a381440 100644 --- a/shared/drizzle/meta/_journal.json +++ b/shared/drizzle/meta/_journal.json @@ -82,8 +82,22 @@ { "idx": 11, "version": "7", - "when": 1781162866140, - "tag": "0011_hesitant_cammi", + "when": 1781085888296, + "tag": "0011_easy_spot", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1781125150955, + "tag": "0012_low_moonstone", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1781165345168, + "tag": "0013_third_darkhawk", "breakpoints": true } ] diff --git a/shared/index.ts b/shared/index.ts index 75df2579b..6ba96dc7d 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -204,6 +204,8 @@ export * from "./models/rewardModels/rewardProgramModels/rewardProgramTable"; export * from "./models/scheduleModels/scheduleTable"; export * from "./models/subModels/subModels"; export * from "./models/subModels/subTable"; +// AI Models +export * from "./models/aiModels/modelsDevTypes"; export * from "./types"; // Agent Types (for pricing agent AI) export * from "./utils/agentTypes"; @@ -219,6 +221,8 @@ export * from "./utils/cusEntUtils/balanceUtils/cusEntToUsageAllowed"; export * from "./utils/cusEntUtils/index"; // Utils export * from "./utils/displayUtils"; +export * from "./utils/featureUtils/buildAiCreditSystemConfig"; +export * from "./utils/featureUtils/resolveInheritedMarkup"; export * from "./utils/fullSubjectUtils"; export * from "./utils/index"; export * from "./utils/intervalUtils"; diff --git a/shared/models/aiModels/modelsDevTypes.ts b/shared/models/aiModels/modelsDevTypes.ts new file mode 100644 index 000000000..db0d72eb5 --- /dev/null +++ b/shared/models/aiModels/modelsDevTypes.ts @@ -0,0 +1,73 @@ +/** Separator between the provider key and the model key in an AI credit `model_id`. */ +export const PROVIDER_SEPARATOR = "/"; + +/** Prefix marking a user-defined model whose price comes from `model_markups`. */ +export const CUSTOM_PROVIDER = "custom"; + +/** + * Split a `model_id` on the first {@link PROVIDER_SEPARATOR}. The model key keeps any + * remaining separators (e.g. openrouter slugs like `openrouter/openai/gpt-4o`). When no + * separator is present the id is a bare model name and `provider` is `undefined`. + */ +export const splitModelId = ( + id: string, +): { provider: string | undefined; modelKey: string } => { + const index = id.indexOf(PROVIDER_SEPARATOR); + if (index === -1) { + return { provider: undefined, modelKey: id }; + } + return { + provider: id.slice(0, index), + modelKey: id.slice(index + PROVIDER_SEPARATOR.length), + }; +}; + +/** Build a canonical `model_id` from a provider key and model key. */ +export const joinModelId = (provider: string, modelKey: string): string => + `${provider}${PROVIDER_SEPARATOR}${modelKey}`; + +/** Whether a `model_id` refers to a custom, user-priced model. */ +export const isCustomModel = (id: string): boolean => + id.startsWith(`${CUSTOM_PROVIDER}${PROVIDER_SEPARATOR}`); + +/** A context-based price tier (e.g. higher rates once the prompt exceeds `size` tokens). */ +export interface ModelsDevCostTier { + input: number; + output: number; + cache_read?: number; + cache_write?: number; + tier: { type: string; size: number }; +} + +/** Per-token rates ($/M tokens) for a model. Only `input`/`output` are guaranteed. */ +export interface ModelsDevCost { + input: number; + output: number; + cache_read?: number; + cache_write?: number; + input_audio?: number; + output_audio?: number; + reasoning?: number; + tiers?: ModelsDevCostTier[]; + context_over_200k?: { + input: number; + output: number; + cache_read?: number; + cache_write?: number; + }; +} + +/** Shape of a single model from the models.dev API */ +export interface ModelsDevModel { + id: string; + name: string; + release_date?: string; + cost: ModelsDevCost; +} + +/** Shape of a provider from the models.dev API */ +export interface ModelsDevProvider { + id: string; + name: string; + models: Record; +} 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/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/cusModels/entityModels/entityTable.ts b/shared/models/cusModels/entityModels/entityTable.ts index a762395ee..2759483a2 100644 --- a/shared/models/cusModels/entityModels/entityTable.ts +++ b/shared/models/cusModels/entityModels/entityTable.ts @@ -76,5 +76,10 @@ export const entities = pgTable( sql`${table.created_at} DESC`, sql`${table.id} DESC`, ), + index("idx_entities_customer_created_at").on( + table.internal_customer_id, + sql`${table.created_at} DESC`, + sql`${table.id} DESC`, + ), ], ); diff --git a/shared/models/cusProductModels/cusEntModels/aggregatedCusEnt.ts b/shared/models/cusProductModels/cusEntModels/aggregatedCusEnt.ts index f890a3865..7333faf12 100644 --- a/shared/models/cusProductModels/cusEntModels/aggregatedCusEnt.ts +++ b/shared/models/cusProductModels/cusEntModels/aggregatedCusEnt.ts @@ -20,6 +20,7 @@ export const AggregatedFeatureBalanceSchema = z.object({ balance: z.number(), adjustment: z.number(), additional_balance: z.number(), + next_reset_at: z.number().nullable(), rollover_balance: z.number().default(0), rollover_usage: z.number().default(0), unlimited: z.boolean(), diff --git a/shared/models/featureModels/featureConfig/creditConfig.ts b/shared/models/featureModels/featureConfig/creditConfig.ts index c75cc8265..63d524d42 100644 --- a/shared/models/featureModels/featureConfig/creditConfig.ts +++ b/shared/models/featureModels/featureConfig/creditConfig.ts @@ -7,16 +7,41 @@ export const CreditSchemaItemSchema = z.object({ credit_amount: z.number(), }); +const MarkupEntrySchema = z.object({ + markup: z.number().min(-100), // percentage markup, e.g. 20 for 20%, -100 for free +}); + +export const ProviderMarkupsSchema = z + .record( + z.string(), // Provider key from the model name, e.g. "openrouter" in "openrouter/anthropic/claude" + MarkupEntrySchema, + ) + .nullish(); + export const CreditSystemConfigSchema = z.object({ schema: z.array( z.object({ metered_feature_id: z.string(), - // feature_amount: z.number(), credit_amount: z.number(), }), ), usage_type: z.nativeEnum(FeatureUsageType), + default_markup: z.number().min(-100).optional(), + provider_markups: ProviderMarkupsSchema, }); +export const ModelMarkupsSchema = z + .record( + z.string(), // Represents the model name in "provider/model" format, e.g. "anthropic/claude-2" + MarkupEntrySchema.extend({ + markup: z.number().min(-100).optional(), // Omit to inherit provider/global markup + input_cost: z.number().min(0).optional(), // $/M tokens, required for custom/ models + output_cost: z.number().min(0).optional(), // $/M tokens, required for custom/ models + }), + ) + .nullish(); + export type CreditSystemConfig = z.infer; export type CreditSchemaItem = z.infer; +export type ModelMarkups = z.infer; +export type ProviderMarkups = z.infer; diff --git a/shared/models/featureModels/featureEnums.ts b/shared/models/featureModels/featureEnums.ts index 430d87a62..c75480114 100644 --- a/shared/models/featureModels/featureEnums.ts +++ b/shared/models/featureModels/featureEnums.ts @@ -2,6 +2,7 @@ export enum FeatureType { Boolean = "boolean", Metered = "metered", CreditSystem = "credit_system", + AiCreditSystem = "ai_credit_system", } export enum AggregateType { diff --git a/shared/models/featureModels/featureModels.ts b/shared/models/featureModels/featureModels.ts index 87fd02e7b..fd9afeff7 100644 --- a/shared/models/featureModels/featureModels.ts +++ b/shared/models/featureModels/featureModels.ts @@ -1,3 +1,4 @@ +import { ModelMarkupsSchema } from "@models/featureModels/featureConfig/creditConfig"; import { z } from "zod/v4"; import { AppEnv } from "../genModels/genEnums"; import { FeatureType } from "./featureEnums"; @@ -22,6 +23,7 @@ export const FeatureSchema = z.object({ .nullish(), archived: z.boolean(), event_names: z.array(z.string()).default([]), + model_markups: ModelMarkupsSchema.nullish(), }); export const CreateFeatureSchema = FeatureSchema.pick({ @@ -31,6 +33,7 @@ export const CreateFeatureSchema = FeatureSchema.pick({ config: true, display: true, event_names: true, + model_markups: true, }); export const MinFeatureSchema = z.object({ diff --git a/shared/models/featureModels/featureTable.ts b/shared/models/featureModels/featureTable.ts index 07cb69d00..4b9d7e81a 100644 --- a/shared/models/featureModels/featureTable.ts +++ b/shared/models/featureModels/featureTable.ts @@ -10,7 +10,7 @@ import { } from "drizzle-orm/pg-core"; import { collatePgColumn } from "../../db/utils"; import { organizations } from "../orgModels/orgTable"; -import type { CreditSystemConfig } from "./featureConfig/creditConfig"; +import type { CreditSystemConfig, ModelMarkups } from "./featureConfig/creditConfig"; import type { MeteredConfig } from "./featureConfig/meteredConfig"; type FeatureDisplay = { @@ -33,6 +33,7 @@ export const features = pgTable( display: jsonb().default(sql`null`).$type(), archived: boolean("archived").notNull().default(false), event_names: text("event_names").array().default([]), + model_markups: jsonb().$type().default(sql`null`), }, (table) => [ foreignKey({ 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/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/models/productModels/priceModels/priceConfig/usagePriceConfig.ts b/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts index e85105231..8fd99c8f9 100644 --- a/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts +++ b/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts @@ -29,7 +29,15 @@ export const UsageTierSchema = z .transform((val) => ({ ...val, amount: val.amount ?? 0, - })); + })) + // zod-openapi can't serialize transforms in output schemas; pipe declares the output shape + .pipe( + z.object({ + to: z.number().or(z.literal(Infinite)), + amount: z.number(), + flat_amount: z.number().optional(), + }), + ); export type UsageTier = z.infer; diff --git a/shared/utils/agentTypes.ts b/shared/utils/agentTypes.ts index b2d0abfdb..8e77a2a2d 100644 --- a/shared/utils/agentTypes.ts +++ b/shared/utils/agentTypes.ts @@ -10,6 +10,10 @@ * - Converters: AgentFeature ↔ Feature, AgentProduct ↔ ProductV2 */ +import type { + ModelMarkups, + ProviderMarkups, +} from "../models/featureModels/featureConfig/creditConfig.js"; import { FeatureType, FeatureUsageType, @@ -19,6 +23,8 @@ import { AppEnv } from "../models/genModels/genEnums.js"; import { Infinite } from "../models/productModels/productEnums.js"; import type { ProductItem } from "../models/productV2Models/productItemModels/productItemModels.js"; import type { ProductV2 } from "../models/productV2Models/productV2Models.js"; +import { isAiCreditSystem } from "@utils/featureUtils/classifyFeature/isAiCreditSystem"; +import { buildAiCreditSystemConfig } from "./featureUtils/buildAiCreditSystemConfig.js"; // ============ INTERFACES ============ @@ -27,7 +33,8 @@ export type AgentFeatureType = | "boolean" | "single_use" | "continuous_use" - | "credit_system"; + | "credit_system" + | "ai_credit_system"; export interface AgentFeature { id: string; @@ -41,6 +48,9 @@ export interface AgentFeature { metered_feature_id: string; credit_cost: number; }> | null; + model_markups?: ModelMarkups; + default_markup?: number | null; + provider_markups?: ProviderMarkups; } export interface AgentProductItem { @@ -84,6 +94,8 @@ function mapAgentTypeToFeatureType(agentType: AgentFeatureType): FeatureType { return FeatureType.Boolean; case "credit_system": return FeatureType.CreditSystem; + case "ai_credit_system": + return FeatureType.AiCreditSystem; default: return FeatureType.Metered; } @@ -116,6 +128,15 @@ export function agentFeatureToFeature(agentFeature: AgentFeature): Feature { credit_amount: s.credit_cost, })); } + if (agentFeature.type === "ai_credit_system") { + Object.assign( + config, + buildAiCreditSystemConfig({ + defaultMarkup: agentFeature.default_markup, + providerMarkups: agentFeature.provider_markups, + }), + ); + } return { internal_id: agentFeature.id, @@ -129,6 +150,7 @@ export function agentFeatureToFeature(agentFeature: AgentFeature): Feature { display: agentFeature.display ?? undefined, archived: false, event_names: [], + model_markups: agentFeature.model_markups ?? null, }; } @@ -172,6 +194,10 @@ export function agentProductToProductV2(product: AgentProduct): ProductV2 { // ============ SHARED → AGENT CONVERTERS ============ function mapFeatureTypeToAgentType(feature: Feature): AgentFeatureType { + if (isAiCreditSystem(feature.type)) { + return "ai_credit_system"; + } + if (feature.type === FeatureType.CreditSystem) { return "credit_system"; } @@ -217,6 +243,11 @@ export function featureToAgentFeature(feature: Feature): AgentFeature { }), ); } + if (isAiCreditSystem(feature.type)) { + agentFeature.model_markups = feature.model_markups; + agentFeature.default_markup = feature.config?.default_markup; + agentFeature.provider_markups = feature.config?.provider_markups; + } return agentFeature; } 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 885ddb26c..1d112b115 100644 --- a/shared/utils/cusEntUtils/index.ts +++ b/shared/utils/cusEntUtils/index.ts @@ -37,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/convertCusProduct/cusProductToConvertedFeatureOptions.ts b/shared/utils/cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.ts index 648984929..2930f3d42 100644 --- a/shared/utils/cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.ts +++ b/shared/utils/cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.ts @@ -1,3 +1,4 @@ +import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct"; import type { FeatureOptions, FullCusProduct, @@ -5,12 +6,55 @@ import type { import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels"; import type { Price } from "@models/productModels/priceModels/priceModels"; import { roundUsageToNearestBillingUnit } from "@utils/billingUtils/usageUtils/roundUsageToNearestBillingUnit"; +import { cusEntsToUsage } from "@utils/cusEntUtils"; +import { findCustomerEntitlementByFeature } from "@utils/cusEntUtils/findCustomerEntitlement/findCustomerEntitlementByFeature"; import { cusPriceToCusEnt } from "@utils/cusPriceUtils"; import { findPrepaidCusPriceByFeature } from "@utils/cusPriceUtils/findCusPriceUtils/findPrepaidCusPriceByFeature"; import { nullish } from "@utils/utils"; import { Decimal } from "decimal.js"; import { cusProductToFeatureOptions } from "./cusProductToFeatureOptions"; +const usageToConvertedFeatureOptions = ({ + cusProduct, + entitlement, + newPrice, +}: { + cusProduct: FullCusProduct; + entitlement: EntitlementWithFeature; + newPrice: Price; +}): FeatureOptions | undefined => { + const customerEntitlement = findCustomerEntitlementByFeature({ + cusEnts: cusProduct.customer_entitlements, + feature: entitlement.feature, + }); + if (!customerEntitlement) return undefined; + + const usage = cusEntsToUsage({ + cusEnts: [ + { + ...customerEntitlement, + customer_product: cusProduct, + } satisfies FullCusEntWithFullCusProduct, + ], + }); + const newAllowance = entitlement.allowance ?? 0; + const newBillingUnits = newPrice.config.billing_units ?? 1; + const paidUsage = Math.max( + 0, + new Decimal(usage).sub(newAllowance).toNumber(), + ); + const roundedPaidUsage = roundUsageToNearestBillingUnit({ + usage: paidUsage, + billingUnits: newBillingUnits, + }); + + return { + internal_feature_id: entitlement.feature.internal_id, + feature_id: entitlement.feature.id, + quantity: new Decimal(roundedPaidUsage).div(newBillingUnits).toNumber(), + }; +}; + /** * Converts purchased packs from an old customer product to packs in new billing units. * Allowance (included usage) is NOT factored in here — it's handled by getStartingBalance. @@ -27,16 +71,22 @@ export const cusProductToConvertedFeatureOptions = ({ const feature = entitlement.feature; const currentOption = cusProductToFeatureOptions({ cusProduct, feature }); - if (nullish(currentOption?.quantity)) return undefined; + // if (nullish(currentOption?.quantity)) return undefined; const oldCusPrice = findPrepaidCusPriceByFeature({ customerPrices: cusProduct.customer_prices, feature, }); - if (!oldCusPrice) - // If no old price found, we can't interpret the stored quantity - return undefined; + // if (!oldCusPrice) return undefined; + + if (nullish(currentOption?.quantity) || !oldCusPrice) { + return usageToConvertedFeatureOptions({ + cusProduct, + entitlement, + newPrice, + }); + } const oldCustomerEntitlement = cusPriceToCusEnt({ cusPrice: oldCusPrice, diff --git a/shared/utils/featureUtils.ts b/shared/utils/featureUtils.ts index de6d0db2e..0448e8073 100644 --- a/shared/utils/featureUtils.ts +++ b/shared/utils/featureUtils.ts @@ -18,7 +18,7 @@ export const toApiFeature = ({ feature }: { feature: Feature }) => { } let creditSchema: CreditSchemaItem[] | undefined; - if (feature.type === FeatureType.CreditSystem) { + if (feature.type === FeatureType.CreditSystem && feature.config?.schema) { creditSchema = feature.config.schema.map((s: CreditSchemaItem) => ({ metered_feature_id: s.metered_feature_id, credit_cost: s.credit_amount, diff --git a/shared/utils/featureUtils/apiFeatureToDbFeature.ts b/shared/utils/featureUtils/apiFeatureToDbFeature.ts index aa1c5bf1a..1df67b00a 100644 --- a/shared/utils/featureUtils/apiFeatureToDbFeature.ts +++ b/shared/utils/featureUtils/apiFeatureToDbFeature.ts @@ -8,6 +8,8 @@ import { FeatureUsageType, } from "@models/featureModels/featureEnums.js"; import type { Feature } from "@models/featureModels/featureModels.js"; +import { isAiCreditSystem } from "@utils/featureUtils/classifyFeature/isAiCreditSystem"; +import { isAnyCreditSystem } from "./classifyFeature/isAnyCreditSystem.js"; import { AppEnv } from "@models/genModels/genEnums.js"; import type { ApiFeatureV1 } from "../../api/features/apiFeatureV1.js"; import type { @@ -23,6 +25,7 @@ import { import type { CreditSchemaItem } from "../../models/featureModels/featureConfig/creditConfig.js"; import type { SharedContext } from "../../types/sharedContext.js"; import { notNullish, nullish } from "../utils.js"; +import { buildAiCreditSystemConfig } from "./buildAiCreditSystemConfig.js"; export const apiFeatureToDbFeature = ({ apiFeature, @@ -72,6 +75,7 @@ export const apiFeatureToDbFeature = ({ config: newConfig, archived: apiFeature.archived ?? originalFeature?.archived ?? false, event_names: [], + model_markups: null, } satisfies Feature; }; @@ -83,6 +87,24 @@ export const featureV1ToDbFeatureConfig = ({ originalFeature: Feature; }) => { const type = apiFeature.type || originalFeature.type; + const hasProviderMarkups = "provider_markups" in apiFeature; + const hasDefaultMarkup = "default_markup" in apiFeature; + + if ( + isAiCreditSystem(type) && + (isAiCreditSystem(apiFeature.type) || + hasDefaultMarkup || + hasProviderMarkups) + ) { + return buildAiCreditSystemConfig({ + defaultMarkup: hasDefaultMarkup + ? apiFeature.default_markup + : originalFeature.config?.default_markup, + providerMarkups: hasProviderMarkups + ? apiFeature.provider_markups + : originalFeature.config?.provider_markups, + }); + } if (nullish(apiFeature.consumable) && nullish(apiFeature.credit_schema)) return; @@ -140,6 +162,16 @@ export const featureV1ToDbFeature = ({ : FeatureUsageType.Continuous; } + if (isAiCreditSystem(apiFeature.type)) { + Object.assign( + newConfig, + buildAiCreditSystemConfig({ + defaultMarkup: apiFeature.default_markup, + providerMarkups: apiFeature.provider_markups, + }), + ); + } + if (apiFeature.credit_schema) { newConfig.usage_type = FeatureUsageType.Single; newConfig.schema = apiFeature.credit_schema.map( @@ -150,6 +182,9 @@ export const featureV1ToDbFeature = ({ ); } + const modelMarkups = + apiFeature.model_markups ?? originalFeature?.model_markups ?? null; + return { internal_id: originalFeature?.internal_id ?? "", org_id: originalFeature?.org_id ?? "", @@ -165,6 +200,7 @@ export const featureV1ToDbFeature = ({ ? apiFeature.archived : (originalFeature?.archived ?? false), event_names: eventNames ?? [], + model_markups: modelMarkups, } satisfies Feature; }; @@ -191,7 +227,7 @@ export const dbToApiFeatureV1 = ({ name: dbFeature.name, type: dbFeature.type, consumable: - dbFeature.type === FeatureType.CreditSystem || + isAnyCreditSystem(dbFeature.type) || dbFeature.config?.usage_type === FeatureUsageType.Single, credit_schema: Array.isArray(dbFeature.config?.schema) @@ -200,6 +236,9 @@ export const dbToApiFeatureV1 = ({ credit_cost: schema.credit_amount, })) : undefined, + model_markups: dbFeature.model_markups ?? undefined, + default_markup: dbFeature.config?.default_markup ?? undefined, + provider_markups: dbFeature.config?.provider_markups ?? undefined, event_names: Array.isArray(dbFeature.event_names) ? dbFeature.event_names : [], diff --git a/shared/utils/featureUtils/buildAiCreditSystemConfig.ts b/shared/utils/featureUtils/buildAiCreditSystemConfig.ts new file mode 100644 index 000000000..f461a17b3 --- /dev/null +++ b/shared/utils/featureUtils/buildAiCreditSystemConfig.ts @@ -0,0 +1,16 @@ +import type { + CreditSystemConfig, + ProviderMarkups, +} from "../../models/featureModels/featureConfig/creditConfig.js"; +import { FeatureUsageType } from "../../models/featureModels/featureEnums.js"; + +/** Single factory for the AiCreditSystem `config` shape. Callers pass already-resolved markup values; no fallback resolution happens here. */ +export const buildAiCreditSystemConfig = (args: { + defaultMarkup?: number | null; + providerMarkups?: ProviderMarkups; +}): CreditSystemConfig => ({ + schema: [], + usage_type: FeatureUsageType.Single, + default_markup: args.defaultMarkup ?? undefined, + provider_markups: args.providerMarkups, +}); diff --git a/shared/utils/featureUtils/classifyFeature/isAiCreditSystem.ts b/shared/utils/featureUtils/classifyFeature/isAiCreditSystem.ts new file mode 100644 index 000000000..02f295e3d --- /dev/null +++ b/shared/utils/featureUtils/classifyFeature/isAiCreditSystem.ts @@ -0,0 +1,5 @@ +import { FeatureType } from "@models/featureModels/featureEnums"; + +export const isAiCreditSystem = ( + type: FeatureType | undefined | null, +): boolean => type === FeatureType.AiCreditSystem; diff --git a/shared/utils/featureUtils/classifyFeature/isAnyCreditSystem.ts b/shared/utils/featureUtils/classifyFeature/isAnyCreditSystem.ts new file mode 100644 index 000000000..eeeefe788 --- /dev/null +++ b/shared/utils/featureUtils/classifyFeature/isAnyCreditSystem.ts @@ -0,0 +1,5 @@ +import { FeatureType } from "@models/featureModels/featureEnums"; +import { isAiCreditSystem } from "@utils/featureUtils/classifyFeature/isAiCreditSystem"; + +export const isAnyCreditSystem = (type: FeatureType): boolean => + type === FeatureType.CreditSystem || isAiCreditSystem(type); diff --git a/shared/utils/featureUtils/creditSystemUtils.ts b/shared/utils/featureUtils/creditSystemUtils.ts index 47c0095a8..039dbffde 100644 --- a/shared/utils/featureUtils/creditSystemUtils.ts +++ b/shared/utils/featureUtils/creditSystemUtils.ts @@ -12,7 +12,8 @@ export const creditSystemContainsFeature = ({ if (creditSystem.type !== FeatureType.CreditSystem) { return false; } - const schema: CreditSchemaItem[] = creditSystem.config.schema; + const schema: CreditSchemaItem[] | undefined = creditSystem.config?.schema; + if (!schema) return false; for (const schemaItem of schema) { if (schemaItem.metered_feature_id === meteredFeatureId) { diff --git a/shared/utils/featureUtils/index.ts b/shared/utils/featureUtils/index.ts index f84b715e1..c94f96905 100644 --- a/shared/utils/featureUtils/index.ts +++ b/shared/utils/featureUtils/index.ts @@ -1,4 +1,6 @@ +import { isAiCreditSystem } from "@utils/featureUtils/classifyFeature/isAiCreditSystem"; import { isAllocatedFeature } from "@utils/featureUtils/classifyFeature/isAllocatedFeature"; +import { isAnyCreditSystem } from "@utils/featureUtils/classifyFeature/isAnyCreditSystem"; import { isConsumableFeature } from "@utils/featureUtils/classifyFeature/isConsumableFeature"; import { findFeatureById } from "@utils/featureUtils/findFeatureUtils"; @@ -9,9 +11,14 @@ export * from "./creditSystemUtils"; export * from "./findFeatureUtils"; export * from "./sortFeatures"; +export { isAiCreditSystem } from "@utils/featureUtils/classifyFeature/isAiCreditSystem"; +export { isAnyCreditSystem } from "@utils/featureUtils/classifyFeature/isAnyCreditSystem"; + export const featureUtils = { isConsumable: isConsumableFeature, isAllocated: isAllocatedFeature, + isAiCreditSystem, + isAnyCreditSystem, find: { byId: findFeatureById, diff --git a/shared/utils/featureUtils/resolveInheritedMarkup.ts b/shared/utils/featureUtils/resolveInheritedMarkup.ts new file mode 100644 index 000000000..60bbfa416 --- /dev/null +++ b/shared/utils/featureUtils/resolveInheritedMarkup.ts @@ -0,0 +1,5 @@ +/** Inherited markup precedence: a provider-level markup wins, otherwise the global default. Returns undefined when neither is set. */ +export const resolveInheritedMarkup = (args: { + providerMarkup?: number | null; + defaultMarkup?: number | null; +}): number | undefined => args.providerMarkup ?? args.defaultMarkup ?? undefined; 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/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/productDisplayUtils.ts b/shared/utils/productDisplayUtils.ts index 1cba7d5a5..f219e76c9 100644 --- a/shared/utils/productDisplayUtils.ts +++ b/shared/utils/productDisplayUtils.ts @@ -14,6 +14,7 @@ import { isFeaturePriceItem, isPriceItem, } from "./productV2Utils/productItemUtils/getItemType.js"; +import { isAiCreditSystem } from "@utils/featureUtils/classifyFeature/isAiCreditSystem"; import { notNullish, nullish } from "./utils.js"; // ============================================================================ @@ -54,7 +55,9 @@ const getIncludedUsageText = (item: ProductItem, feature: Feature): string => { if (item.included_usage === Infinite) { return `Unlimited ${featureName}`; } - + if (isAiCreditSystem(feature.type)) { + return `$${numberWithCommas(item.included_usage ?? 0)} of ${featureName}`; + } if (nullish(item.included_usage) || item.included_usage === 0) { return `0 ${featureName}`; } @@ -224,9 +227,14 @@ export const getFeaturePriceItemDisplay = ({ feature, units: item.included_usage, }); - const includedUsageStr = hasIncludedUsage - ? `${numberWithCommas(includedUsage)} ${includedFeatureName}` - : ""; + let includedUsageStr = ""; + if (hasIncludedUsage) { + if (isAiCreditSystem(feature.type)) { + includedUsageStr = `$${numberWithCommas(includedUsage)} of ${includedFeatureName}`; + } else { + includedUsageStr = `${numberWithCommas(includedUsage)} ${includedFeatureName}`; + } + } const volumeFlatAmount = isVolumeFlatAmountItem(item); @@ -249,10 +257,18 @@ export const getFeaturePriceItemDisplay = ({ feature, units: billingUnits, }); - const perUnitStr = - billingUnits > 1 - ? `${numberWithCommas(billingUnits)} ${billingFeatureName}` - : billingFeatureName; + let perUnitStr: string; + if (isAiCreditSystem(feature.type)) { + perUnitStr = + billingUnits > 1 + ? `$${numberWithCommas(billingUnits)} of ${billingFeatureName}` + : `$1 of ${billingFeatureName}`; + } else { + perUnitStr = + billingUnits > 1 + ? `${numberWithCommas(billingUnits)} ${billingFeatureName}` + : billingFeatureName; + } // Build interval string const showInterval = isMainPrice || fullDisplay; @@ -267,6 +283,13 @@ export const getFeaturePriceItemDisplay = ({ } // Format output based on what we have + if (isAiCreditSystem(feature.type)) { + return { + primary_text: includedUsageStr || "$0 included", + secondary_text: "then charged based on model usage", + }; + } + if (hasIncludedUsage) { if (volumeFlatAmount) { const featureName = getFeatureName({ feature, units: 2 }); 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 1f9b50d32..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, 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/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 && ( ( 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 ( - + - -
- {redisConfig.migrationPercent > 0 && ( -

- Set migration to 0% before removing the Redis connection. -

- )} -
- ) : ( -
-
- Connection String - setConnectionString(event.target.value)} - className="font-mono text-xs" - /> -
-
- -
-
- )} - -
- - - - - Connect Redis - - Migration starts at 0%. No customers will be routed until the - migration percentage is increased. - - - - - - - - - - - - - Update Migration Percentage - - Customers are assigned deterministically by customer ID. - - -
- - Current: {redisConfig?.migrationPercent ?? 0}% / New: - -
- setNewMigrationPercent(event.target.value)} - className="w-24 font-mono text-xs" - /> - % -
-
- - - - -
-
- - - - - Remove Redis Connection - - This org will revert to the shared Redis instance. Type{" "} - "{CONFIRM_REMOVE_TEXT}" to - confirm. - - - setRemoveConfirmText(event.target.value)} - variant="destructive" - /> - - - - - - - - ); -}; diff --git a/vite/src/views/main-sidebar/CollapsibleNavGroup.tsx b/vite/src/views/main-sidebar/CollapsibleNavGroup.tsx index f4b5ecb1f..b431e280f 100644 --- a/vite/src/views/main-sidebar/CollapsibleNavGroup.tsx +++ b/vite/src/views/main-sidebar/CollapsibleNavGroup.tsx @@ -19,6 +19,7 @@ interface SubTab { value: string; icon?: ReactNode; path?: string; + badge?: ReactNode; } interface CollapsibleNavGroupProps { @@ -117,6 +118,7 @@ export const CollapsibleNavGroup = ({ subValue={subTab.path ? undefined : subTab.value} icon={subTab.icon} title={keyToTitle(subTab.title)} + badge={subTab.badge} isSubNav /> ))} diff --git a/vite/src/views/main-sidebar/FeedbackDialog.tsx b/vite/src/views/main-sidebar/FeedbackDialog.tsx deleted file mode 100644 index a1da7076b..000000000 --- a/vite/src/views/main-sidebar/FeedbackDialog.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { ChatCircleTextIcon } from "@phosphor-icons/react"; -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 { LongInput } from "@/components/v2/inputs/LongInput"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { useEnv } from "@/utils/envUtils"; -import { NavButton } from "./NavButton"; - -export function FeedbackDialog() { - const env = useEnv(); - const axiosInstance = useAxiosInstance({ env }); - const [open, setOpen] = useState(false); - const [feedback, setFeedback] = useState(""); - const [loading, setLoading] = useState(false); - - const handleSubmit = async () => { - if (!feedback.trim()) return; - - setLoading(true); - try { - await axiosInstance.post("/feedback", { feedback }); - - toast.success("Thanks for your feedback!"); - setFeedback(""); - setOpen(false); - } catch (error) { - console.error("Failed to send feedback:", error); - toast.error("Failed to send feedback"); - } finally { - setLoading(false); - } - }; - - return ( - <> - } - title="Feedback" - isGroup - onClick={() => setOpen(true)} - /> - - - - 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/main-sidebar/MainSidebar.tsx b/vite/src/views/main-sidebar/MainSidebar.tsx index 3433de499..5ba4d0355 100644 --- a/vite/src/views/main-sidebar/MainSidebar.tsx +++ b/vite/src/views/main-sidebar/MainSidebar.tsx @@ -4,7 +4,6 @@ import { BasketIcon, ChartBarIcon, CubeIcon, - DatabaseIcon, GearIcon, KeyIcon, LegoIcon, @@ -14,8 +13,10 @@ import { UsersIcon, WebhooksLogoIcon, } from "@phosphor-icons/react"; +import { Scopes } from "@autumn/shared"; import { PanelLeft } from "lucide-react"; import { useHotkeys } from "react-hotkeys-hook"; +import { BetaBadge } from "@/components/v2/badges/BetaBadge"; import { Button } from "@/components/v2/buttons/Button"; import { RevenueCatIcon, StripeIcon } from "@/components/v2/icons/AutumnIcons"; import { useAutumnFlags } from "@/hooks/common/useAutumnFlags"; @@ -23,7 +24,6 @@ import { useLocalStorage } from "@/hooks/common/useLocalStorage"; import { useScopes } from "@/hooks/useScopes"; import { cn } from "@/lib/utils"; import { useEnv } from "@/utils/envUtils"; -import { useAdmin } from "@/views/admin/hooks/useAdmin"; import { CollapsibleNavGroup } from "./CollapsibleNavGroup"; import { OrgDropdown } from "./components/OrgDropdown"; import { EnvDropdown } from "./EnvDropdown"; @@ -34,14 +34,12 @@ import { SidebarRail } from "./SidebarRail"; const buildDevSubTabs = ({ flags, - isAdmin, }: { flags: { webhooks: boolean; vercel: boolean; revenuecat: boolean; }; - isAdmin: boolean; }) => { return [ { @@ -82,15 +80,6 @@ const buildDevSubTabs = ({ }, ] : []), - ...(isAdmin - ? [ - { - title: "Redis", - value: "redis", - icon: , - }, - ] - : []), ]; }; @@ -103,8 +92,8 @@ export const MainSidebar = ({ const flags = useAutumnFlags(); const { has } = useScopes(); - const { isAdmin } = useAdmin(); - const canSeeDev = has("apiKeys:read"); + const canSeeDev = has(Scopes.ApiKeys.Read); + const canSeeMigrations = has(Scopes.Migrations.Read); const [storedExpanded, setExpanded] = useLocalStorage( "sidebar.expanded", @@ -194,7 +183,7 @@ export const MainSidebar = ({ }, ]} /> - {isAdmin ? ( + {canSeeMigrations ? ( } @@ -213,6 +202,7 @@ export const MainSidebar = ({ value: "migrations", path: "/migrations", icon: , + badge: , }, ]} /> @@ -238,7 +228,7 @@ export const MainSidebar = ({ env={env} isOpen={devGroupOpen} onToggle={() => setDevGroupOpen((prev) => !prev)} - subTabs={buildDevSubTabs({ flags, isAdmin })} + subTabs={buildDevSubTabs({ flags })} /> )} { // Get window path const finalEnv = useEnv(); @@ -67,6 +69,7 @@ export const NavButton = ({ > {title} + {badge && expanded && badge} {online && ( diff --git a/vite/src/views/main-sidebar/SidebarBottom.tsx b/vite/src/views/main-sidebar/SidebarBottom.tsx index a5cec042f..74a1e7915 100644 --- a/vite/src/views/main-sidebar/SidebarBottom.tsx +++ b/vite/src/views/main-sidebar/SidebarBottom.tsx @@ -1,9 +1,8 @@ "use client"; -import { BooksIcon, DiscordLogoIcon } from "@phosphor-icons/react"; +import { BooksIcon } from "@phosphor-icons/react"; import { useEnv } from "@/utils/envUtils"; import { WorkbenchButton } from "@/views/customers2/customer/workbench/WorkbenchButton"; -import { FeedbackDialog } from "./FeedbackDialog"; import { NavButton } from "./NavButton"; import { SidebarContact } from "./SidebarContact"; import { useSidebarContext } from "./SidebarContext"; @@ -23,15 +22,6 @@ export default function SidebarBottom() { env={env} href="https://docs.useautumn.com" /> - - } - title="Discord" - online={expanded} - env={env} - href="https://discord.gg/STqxY92zuS" - /> 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 a7f42e11f..24533b873 100644 --- a/vite/src/views/migrations/migration/MigrationView.tsx +++ b/vite/src/views/migrations/migration/MigrationView.tsx @@ -2,6 +2,7 @@ import { motion } from "motion/react"; import { useCallback, useEffect } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { useNavigate, useParams } from "react-router"; +import { AdminHover } from "@/components/general/AdminHover"; import { Breadcrumb, BreadcrumbItem, @@ -73,7 +74,16 @@ export function MigrationView() { - {migration.id} + + {migration.id} + 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/components/CreateFeatureSheet.tsx b/vite/src/views/products/features/components/CreateFeatureSheet.tsx index fed83c894..469995c00 100644 --- a/vite/src/views/products/features/components/CreateFeatureSheet.tsx +++ b/vite/src/views/products/features/components/CreateFeatureSheet.tsx @@ -1,8 +1,7 @@ import { CreateFeatureSchema, - type CreditSchemaItem, - FeatureType, FeatureUsageType, + isAnyCreditSystem, } from "@autumn/shared"; import type { AxiosError } from "axios"; import { useEffect, useState } from "react"; @@ -23,6 +22,7 @@ import { NewFeatureBehaviour } from "../../plan/components/new-feature/NewFeatur import { NewFeatureDetails } from "../../plan/components/new-feature/NewFeatureDetails"; import { NewFeatureType } from "../../plan/components/new-feature/NewFeatureType"; import { validateCreditSystem } from "../credit-systems/utils/validateCreditSystem"; +import { buildFeatureMarkupParams } from "../utils/buildFeatureMutationParams"; import { getDefaultFeature } from "../utils/defaultFeature"; function CreateFeatureSheet({ @@ -52,7 +52,7 @@ function CreateFeatureSheet({ const handleCreateFeature = async () => { // Validate credit system specific fields first - if (feature.type === FeatureType.CreditSystem) { + if (isAnyCreditSystem(feature.type)) { const validationError = validateCreditSystem(feature); if (validationError) { toast.error(validationError); @@ -77,12 +77,13 @@ function CreateFeatureSheet({ id: feature.id, type: feature.type, consumable: feature.config?.usage_type === FeatureUsageType.Single, - credit_schema: feature.config?.schema?.map( - (x: CreditSchemaItem) => ({ - metered_feature_id: x.metered_feature_id, - credit_cost: x.credit_amount, - }), - ), + ...buildFeatureMarkupParams({ + type: feature.type, + modelMarkups: feature.model_markups ?? undefined, + defaultMarkup: feature.config?.default_markup, + providerMarkups: feature.config?.provider_markups, + schema: feature.config?.schema, + }), event_names: feature.event_names, }, ); @@ -119,13 +120,6 @@ function CreateFeatureSheet({ return ( - {/* {!isControlled && ( - - - - )} */} +
+ Default Markup % + { + const raw = e.target.value; + if (raw === "" || /^-?\d*\.?\d*$/.test(raw)) { + form.setFieldValue("defaultMarkup", raw === "" ? 0 : Number(raw)); + } + }} + placeholder="0" + /> +
+ + {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 ( + + ); + })} +
+ )} + +
e.stopPropagation()} + > + + Add Provider Override + + + + + + Add specific markup overrides for certain providers/models. + + + + p.id} + getOptionLabel={(p) => p.name} + renderValue={() => ( + Select provider + )} + placeholder="Select provider" + searchable + searchPlaceholder="Search providers..." + emptyText="No providers available" + disabled={isLoading} + /> +
+
+ ); +} diff --git a/vite/src/views/products/features/credit-systems/components/AiCreditSchemaTable.tsx b/vite/src/views/products/features/credit-systems/components/AiCreditSchemaTable.tsx new file mode 100644 index 000000000..6ad09edc1 --- /dev/null +++ b/vite/src/views/products/features/credit-systems/components/AiCreditSchemaTable.tsx @@ -0,0 +1,337 @@ +import { + joinModelId, + type ModelsDevProvider, + splitModelId, +} from "@autumn/shared"; +import type { ColumnDef, Row } from "@tanstack/react-table"; +import { InfoIcon, PlusIcon, X } from "lucide-react"; +import { useMemo } from "react"; +import { Table } from "@/components/general/table"; +import { IconButton } from "@/components/v2/buttons/IconButton"; +import { Input } from "@/components/v2/inputs/Input"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/v2/tooltips/Tooltip"; +import { useProductTable } from "@/views/products/hooks/useProductTable"; +import type { CreditSystemFormInstance } from "../hooks/useCreditSystemForm"; +import { useProviderMarkup } from "../hooks/useProviderMarkup"; +import { addCustomModelMarkup } from "../utils/modelMarkupUtils"; +import { AiModelSelectDropdown } from "./AiModelSelectDropdown"; +import { CustomModelInput } from "./CustomModelInput"; +import { EditableNumberCell } from "./EditableNumberCell"; + +interface ModelRow { + fullId: string; + modelKey: string; +} + +function MarkupCell({ + form, + fullId, + providerKey, +}: { + form: CreditSystemFormInstance; + fullId: string; + providerKey: string; +}) { + const { inheritedMarkup } = useProviderMarkup(form, providerKey); + + return ( + + ); +} + +interface AiCreditSchemaTableProps { + form: CreditSystemFormInstance; + providerKey: string; + providerName: string; + modelFullIds: string[]; + provider: ModelsDevProvider; + isLoading: boolean; + removeKeys: (keys: string[]) => void; + removeProvider: (providerKey: string) => void; + setProviderMarkup: (providerKey: string, value: number | undefined) => void; + renameKey: (oldKey: string, newKey: string) => void; +} + +function formatCost(value: number | null | undefined): string { + if (value == null) return "–"; + return value.toFixed(2); +} + +export function AiCreditSchemaTable({ + form, + providerKey, + providerName, + modelFullIds, + provider, + isLoading, + removeKeys, + removeProvider, + setProviderMarkup, + renameKey, +}: AiCreditSchemaTableProps) { + const isCustom = providerKey === "custom"; + + const { defaultMarkup, providerMarkup } = useProviderMarkup( + form, + providerKey, + ); + + const data: ModelRow[] = useMemo( + () => + modelFullIds.map((fullId) => ({ + fullId, + modelKey: splitModelId(fullId).modelKey, + })), + [modelFullIds.join(",")], + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + header: "Model", + accessorKey: "modelKey", + size: 200, + cell: ({ row }: { row: Row }) => { + const { modelKey } = row.original; + if (isCustom) { + return ( + + renameKey( + joinModelId(providerKey, modelKey), + joinModelId(providerKey, newKey), + ) + } + /> + ); + } + return ( + + renameKey( + joinModelId(providerKey, modelKey), + joinModelId(providerKey, newKey), + ) + } + provider={provider} + isLoading={isLoading} + /> + ); + }, + }, + { + header: isCustom ? "In $/M" : "Input", + id: "inputCost", + size: 80, + cell: ({ row }: { row: Row }) => { + const { fullId, modelKey } = row.original; + if (isCustom) { + return ( + + ); + } + const cost = provider.models[modelKey]?.cost?.input ?? null; + return ( + + {formatCost(cost)} + + ); + }, + }, + { + header: isCustom ? "Out $/M" : "Output", + id: "outputCost", + size: 80, + cell: ({ row }: { row: Row }) => { + const { fullId, modelKey } = row.original; + if (isCustom) { + return ( + + ); + } + const cost = provider.models[modelKey]?.cost?.output ?? null; + return ( + + {formatCost(cost)} + + ); + }, + }, + { + header: "Markup %", + id: "markup", + size: 80, + cell: ({ row }: { row: Row }) => ( + + ), + }, + { + header: "", + accessorKey: "actions", + size: 40, + enableSorting: false, + cell: ({ row }: { row: Row }) => ( +
e.stopPropagation()} + > + } + onClick={() => removeKeys([row.original.fullId])} + className="!text-subtle hover:!text-foreground" + /> +
+ ), + }, + ], + [isCustom, provider, isLoading, providerKey, form], + ); + + const allModelsUsed = + !isCustom && Object.keys(provider.models).length === modelFullIds.length; + + const table = useProductTable({ + data, + columns, + options: { getRowId: (row) => row.fullId }, + }); + + return ( +
+
+ + {providerName} + {!isCustom && ( + {providerName} + )} + {isCustom && ( + + + + + + Use format{" "} + + custom/modelId + {" "} + in API tracking + + + )} + +
+ {!isCustom && ( +
+ Markup % + { + const raw = e.target.value; + if (raw === "" || /^-?\d*\.?\d*$/.test(raw)) { + if (raw === "") { + setProviderMarkup(providerKey, undefined); + } else { + const parsed = Number(raw); + if (!Number.isNaN(parsed)) { + setProviderMarkup(providerKey, parsed); + } + } + } + }} + placeholder={String(defaultMarkup)} + className="w-20" + /> +
+ )} + } + onClick={() => removeProvider(providerKey)} + className="!text-subtle hover:!text-foreground" + /> +
+
+ +
+ + + + + + + + + + {!allModelsUsed && ( + + )} +
+
+ ); +} diff --git a/vite/src/views/products/features/credit-systems/components/AiModelSelectDropdown.tsx b/vite/src/views/products/features/credit-systems/components/AiModelSelectDropdown.tsx new file mode 100644 index 000000000..e333cd314 --- /dev/null +++ b/vite/src/views/products/features/credit-systems/components/AiModelSelectDropdown.tsx @@ -0,0 +1,53 @@ +import type { ModelsDevModel, ModelsDevProvider } from "@autumn/shared"; +import { useMemo } from "react"; +import { SearchableSelect } from "@/components/v2/selects/SearchableSelect"; + +interface AiModelSelectDropdownProps { + value: string; + onValueChange: (modelKey: string) => void; + provider: ModelsDevProvider; + isLoading: boolean; +} + +export function AiModelSelectDropdown({ + value, + onValueChange, + provider, + isLoading, +}: AiModelSelectDropdownProps) { + const models: ModelsDevModel[] = useMemo( + () => Object.values(provider.models), + [provider], + ); + + return ( +
e.stopPropagation()}> + model.id} + getOptionLabel={(model) => model.name} + renderValue={(option) => + option ? ( + {option.name} + ) : provider.models[value]?.name ? ( + {provider.models[value].name} + ) : value ? ( + {value} + ) : ( + + {isLoading ? "Loading..." : "Select model"} + + ) + } + placeholder={isLoading ? "Loading..." : "Select model"} + searchable + searchPlaceholder="Search models..." + emptyText="No models found" + disabled={isLoading} + /> +
+ ); +} diff --git a/vite/src/views/products/features/credit-systems/components/ClassicCreditSchema.tsx b/vite/src/views/products/features/credit-systems/components/ClassicCreditSchema.tsx new file mode 100644 index 000000000..9c2817c2f --- /dev/null +++ b/vite/src/views/products/features/credit-systems/components/ClassicCreditSchema.tsx @@ -0,0 +1,113 @@ +import { + type CreditSchemaItem, + type Feature, + isAiCreditSystem, +} from "@autumn/shared"; +import { PlusIcon } from "@phosphor-icons/react"; +import { X } from "lucide-react"; +import { IconButton } from "@/components/v2/buttons/IconButton"; +import { FormLabel } from "@/components/v2/form/FormLabel"; +import { Input } from "@/components/v2/inputs/Input"; +import { useCreditSchema } from "../hooks/useCreditSchema"; +import type { CreditSystemFormInstance } from "../hooks/useCreditSystemForm"; +import { FeatureSelectDropdown } from "./FeatureSelectDropdown"; + +interface ClassicCreditSchemaProps { + form: CreditSystemFormInstance; +} + +export function ClassicCreditSchema({ form }: ClassicCreditSchemaProps) { + const { + schema, + schemaKeys, + allSchemaCandidateFeatures, + handleSchemaChange, + addSchemaItem, + removeSchemaItem, + } = useCreditSchema(form); + + return ( +
+
+ Feature + Credit Cost +
+ +
+ {schema.map((item: CreditSchemaItem, index: number) => { + const availableFeatures = allSchemaCandidateFeatures.filter( + (feature: Feature) => + !schema.some( + (schemaItem: CreditSchemaItem) => + feature.id !== item.metered_feature_id && + schemaItem.metered_feature_id === feature.id, + ), + ); + + const selectedFeature = allSchemaCandidateFeatures.find( + (f: Feature) => f.id === item.metered_feature_id, + ); + const isAiChild = isAiCreditSystem(selectedFeature?.type); + + return ( +
+ + handleSchemaChange(index, "metered_feature_id", featureId) + } + availableFeatures={availableFeatures} + allFeatures={allSchemaCandidateFeatures} + /> + +
+
+ + handleSchemaChange(index, "credit_amount", e.target.value) + } + onBlur={(e) => + handleSchemaChange( + index, + "credit_amount", + Number(e.target.value) || 0, + ) + } + placeholder="eg. 10" + /> + } + onClick={() => removeSchemaItem(index)} + /> +
+ {isAiChild && ( + + credits per $1 of AI usage + + )} +
+
+ ); + })} +
+ + = allSchemaCandidateFeatures.length} + className="w-fit mt-4" + icon={} + > + Add + +
+ ); +} diff --git a/vite/src/views/products/features/credit-systems/components/CreditSystemDetails.tsx b/vite/src/views/products/features/credit-systems/components/CreditSystemDetails.tsx index b1c834f36..7e273c280 100644 --- a/vite/src/views/products/features/credit-systems/components/CreditSystemDetails.tsx +++ b/vite/src/views/products/features/credit-systems/components/CreditSystemDetails.tsx @@ -1,23 +1,17 @@ -import type { CreateFeature } from "@autumn/shared"; +import { useStore } from "@tanstack/react-form"; import { FormLabel } from "@/components/v2/form/FormLabel"; import { Input } from "@/components/v2/inputs/Input"; import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; -import { useAutoSlug } from "@/hooks/common/useAutoSlug"; +import { slugify } from "@/utils/formatUtils/formatTextUtils"; +import type { CreditSystemFormInstance } from "../hooks/useCreditSystemForm"; interface CreditSystemDetailsProps { - creditSystem: CreateFeature; - setCreditSystem: (creditSystem: CreateFeature) => void; + form: CreditSystemFormInstance; } -export function CreditSystemDetails({ - creditSystem, - setCreditSystem, -}: CreditSystemDetailsProps) { - const { setSource, setTarget } = useAutoSlug({ - setState: setCreditSystem, - sourceKey: "name", - targetKey: "id", - }); +export function CreditSystemDetails({ form }: CreditSystemDetailsProps) { + const name = useStore(form.store, (s) => s.values.name); + const id = useStore(form.store, (s) => s.values.id); return ( @@ -26,16 +20,21 @@ export function CreditSystemDetails({ Name setSource(e.target.value)} + value={name} + onChange={(e) => { + form.setFieldValue("name", e.target.value); + if (!id || id === slugify(name)) { + form.setFieldValue("id", slugify(e.target.value)); + } + }} />
ID setTarget(e.target.value)} + value={id} + onChange={(e) => form.setFieldValue("id", e.target.value)} />
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 2cef9ff85..14cffbab1 100644 --- a/vite/src/views/products/features/credit-systems/components/CreditSystemSchema.tsx +++ b/vite/src/views/products/features/credit-systems/components/CreditSystemSchema.tsx @@ -1,145 +1,88 @@ -import type { CreateFeature, CreditSchemaItem, Feature } from "@autumn/shared"; -import { FeatureType } from "@autumn/shared"; -import { PlusIcon } from "@phosphor-icons/react"; -import { X } from "lucide-react"; -import { toast } from "sonner"; -import { IconButton } from "@/components/v2/buttons/IconButton"; -import { FormLabel } from "@/components/v2/form/FormLabel"; -import { Input } from "@/components/v2/inputs/Input"; +import { FeatureType, isAiCreditSystem } from "@autumn/shared"; +import { useStore } from "@tanstack/react-form"; +import { useMemo } from "react"; +import { BetaBadge } from "@/components/v2/badges/BetaBadge"; +import { GroupedTabButton } from "@/components/v2/buttons/GroupedTabButton"; import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; -import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; -import { FeatureSelectDropdown } from "@/views/products/features/credit-systems/components/FeatureSelectDropdown"; +import type { CreditSystemFormInstance } from "../hooks/useCreditSystemForm"; +import { AiCreditSchema } from "./AiCreditSchema"; +import { ClassicCreditSchema } from "./ClassicCreditSchema"; + +type CreditSchemaMode = "classic" | "ai"; interface CreditSystemSchemaProps { - creditSystem: CreateFeature; - setCreditSystem: (creditSystem: CreateFeature) => void; + form: CreditSystemFormInstance; + disableModeSwitch?: boolean; } export function CreditSystemSchema({ - creditSystem, - setCreditSystem, + form, + disableModeSwitch = false, }: CreditSystemSchemaProps) { - const { features } = useFeaturesQuery(); + const type = useStore(form.store, (s) => s.values.type); - const schema = creditSystem.config?.schema || []; + const mode: CreditSchemaMode = isAiCreditSystem(type) ? "ai" : "classic"; - const handleSchemaChange = ( - index: number, - key: keyof CreditSchemaItem, - value: string | number, - ) => { - const newSchema = [...schema]; - newSchema[index] = { ...newSchema[index], [key]: value }; - setCreditSystem({ - ...creditSystem, - config: { ...creditSystem.config, schema: newSchema }, - }); - }; - - const addSchemaItem = () => { - const newSchema = [ - ...schema, - { - metered_feature_id: "", - feature_amount: 1, - credit_amount: 0, - }, - ]; - setCreditSystem({ - ...creditSystem, - config: { ...creditSystem.config, schema: newSchema }, - }); - }; - - const removeSchemaItem = (index: number) => { - if (schema.length === 1) { - toast.error("There must be at least one feature in the credit system"); - return; + const handleModeChange = (newMode: string) => { + if (newMode === "ai") { + form.setFieldValue("type", FeatureType.AiCreditSystem); + form.setFieldValue("config", { ...form.state.values.config, schema: [] }); + form.setFieldValue("model_markups", {}); + form.setFieldValue("provider_markups", {}); + } else { + form.setFieldValue("type", FeatureType.CreditSystem); + form.setFieldValue("config", { + ...form.state.values.config, + schema: [ + { metered_feature_id: "", feature_amount: 1, credit_amount: 0 }, + ], + }); + form.setFieldValue("model_markups", {}); + form.setFieldValue("provider_markups", {}); } - const newSchema = [...schema]; - newSchema.splice(index, 1); - setCreditSystem({ - ...creditSystem, - config: { ...creditSystem.config, schema: newSchema }, - }); }; - const allMeteredFeatures = features.filter( - (feature: Feature) => feature.type === FeatureType.Metered, + const modeOptions = useMemo( + () => [ + { value: "classic", label: "Classic" }, + { + value: "ai", + label: ( + + AI + + + ), + }, + ], + [], ); return ( -
-
- Metered Feature - Credit Cost -
+
+ {!disableModeSwitch && ( + + )} -
- {schema.map((item: CreditSchemaItem, index: number) => { - const availableFeatures = allMeteredFeatures.filter( - (feature: Feature) => - !schema.some( - (schemaItem: CreditSchemaItem) => - feature.id !== item.metered_feature_id && - schemaItem.metered_feature_id === feature.id, - ), - ); - - return ( -
- - handleSchemaChange(index, "metered_feature_id", featureId) - } - availableFeatures={availableFeatures} - allFeatures={allMeteredFeatures} - /> - -
- - handleSchemaChange(index, "credit_amount", e.target.value) - } - onBlur={(e) => - handleSchemaChange( - index, - "credit_amount", - Number(e.target.value) || 0, - ) - } - placeholder="eg. 10" - /> - } - onClick={() => removeSchemaItem(index)} - /> -
-
- ); - })} -
- - = allMeteredFeatures.length} - className="w-fit mt-4" - icon={} - > - Add - + {mode === "classic" ? ( + + ) : ( + + )}
); diff --git a/vite/src/views/products/features/credit-systems/components/CustomModelInput.tsx b/vite/src/views/products/features/credit-systems/components/CustomModelInput.tsx new file mode 100644 index 000000000..968abfb4e --- /dev/null +++ b/vite/src/views/products/features/credit-systems/components/CustomModelInput.tsx @@ -0,0 +1,23 @@ +import { useState } from "react"; +import { Input } from "@/components/v2/inputs/Input"; + +interface CustomModelInputProps { + modelKey: string; + onRename: (newKey: string) => void; +} + +export function CustomModelInput({ modelKey, onRename }: CustomModelInputProps) { + const [local, setLocal] = useState(modelKey); + return ( + setLocal(e.target.value)} + onBlur={() => { + if (local !== modelKey) onRename(local); + }} + placeholder="my-model-id" + className="text-sm" + /> + ); +} diff --git a/vite/src/views/products/features/credit-systems/components/EditableNumberCell.tsx b/vite/src/views/products/features/credit-systems/components/EditableNumberCell.tsx new file mode 100644 index 000000000..d949b212e --- /dev/null +++ b/vite/src/views/products/features/credit-systems/components/EditableNumberCell.tsx @@ -0,0 +1,81 @@ +import { useStore } from "@tanstack/react-form"; +import { useState } from "react"; +import { Input } from "@/components/v2/inputs/Input"; +import type { CreditSystemFormInstance } from "../hooks/useCreditSystemForm"; + +interface EditableNumberCellProps { + form: CreditSystemFormInstance; + fullId: string; + field: "markup" | "input_cost" | "output_cost"; + useDefaultAsPlaceholder?: boolean; + /** Effective inherited markup (provider default, else global default) shown as the placeholder. */ + inheritedPlaceholder?: number; + allowUndefined?: boolean; +} + +export function EditableNumberCell({ + form, + fullId, + field, + useDefaultAsPlaceholder = false, + inheritedPlaceholder = 0, + allowUndefined = false, +}: EditableNumberCellProps) { + const currentValue = useStore( + form.store, + (s) => s.values.model_markups[fullId]?.[field], + ); + const placeholder = useDefaultAsPlaceholder + ? String(inheritedPlaceholder) + : "0"; + const [local, setLocal] = useState(""); + const [focused, setFocused] = useState(false); + + const hasValue = currentValue != null; + const displayed = focused ? local : hasValue ? String(currentValue) : ""; + + return ( + { + const raw = e.target.value; + if (raw === "" || /^-?\d*\.?\d*$/.test(raw)) { + setLocal(raw); + if (raw === "" && allowUndefined) { + form.setFieldValue("model_markups", (prev) => { + const entry = { ...prev[fullId] }; + delete entry[field]; + return { ...prev, [fullId]: entry }; + }); + } else if (raw !== "") { + const parsed = Number(raw); + if (!Number.isNaN(parsed)) { + form.setFieldValue("model_markups", (prev) => ({ + ...prev, + [fullId]: { ...prev[fullId], [field]: parsed }, + })); + } + } + } + }} + onFocus={() => { + setLocal(hasValue ? String(currentValue) : ""); + setFocused(true); + }} + onBlur={() => { + setFocused(false); + if (local === "" && !allowUndefined) { + form.setFieldValue("model_markups", (prev) => ({ + ...prev, + [fullId]: { ...prev[fullId], [field]: 0 }, + })); + } + }} + placeholder={placeholder} + className="text-sm" + /> + ); +} diff --git a/vite/src/views/products/features/credit-systems/components/UpdateCreditSystemSheet.tsx b/vite/src/views/products/features/credit-systems/components/UpdateCreditSystemSheet.tsx index 16d280699..a81747c4f 100644 --- a/vite/src/views/products/features/credit-systems/components/UpdateCreditSystemSheet.tsx +++ b/vite/src/views/products/features/credit-systems/components/UpdateCreditSystemSheet.tsx @@ -1,7 +1,6 @@ -import type { CreateFeature, CreditSchemaItem, Feature } from "@autumn/shared"; -import { FeatureType } from "@autumn/shared"; +import type { CreditSchemaItem, Feature } from "@autumn/shared"; +import { useStore } from "@tanstack/react-form"; import type { AxiosError } from "axios"; -import { useEffect, useState } from "react"; import { toast } from "sonner"; import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; import { @@ -13,6 +12,8 @@ import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { FeatureService } from "@/services/FeatureService"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { getBackendErr } from "@/utils/genUtils"; +import { buildFeatureMarkupParams } from "../../utils/buildFeatureMutationParams"; +import { useCreditSystemForm } from "../hooks/useCreditSystemForm"; import { validateCreditSystem } from "../utils/validateCreditSystem"; import { CreditSystemDetails } from "./CreditSystemDetails"; import { CreditSystemSchema } from "./CreditSystemSchema"; @@ -30,127 +31,96 @@ function UpdateCreditSystemSheet({ selectedCreditSystem, onSuccess, }: UpdateCreditSystemSheetProps) { - const [loading, setLoading] = useState(false); - const [creditSystem, setCreditSystem] = useState({ - name: "", - id: "", - type: FeatureType.CreditSystem, - config: { - schema: [ - { - metered_feature_id: "", - feature_amount: 1, - credit_amount: 0, - }, - ], - }, - event_names: [], - }); - const axiosInstance = useAxiosInstance(); const { refetch } = useFeaturesQuery(); - // Initialize credit system when selectedCreditSystem changes - useEffect(() => { - if (open && selectedCreditSystem) { - setCreditSystem({ - name: selectedCreditSystem.name, - id: selectedCreditSystem.id, - type: selectedCreditSystem.type, - config: selectedCreditSystem.config, - event_names: selectedCreditSystem.event_names, - }); - } - }, [open, selectedCreditSystem]); + const form = useCreditSystemForm({ + feature: open ? selectedCreditSystem : null, + onSubmit: async (values) => { + if (!selectedCreditSystem) return; - const handleUpdateCreditSystem = async () => { - if (!selectedCreditSystem) return; + const creditSystem = { + name: values.name, + id: values.id, + type: values.type, + config: values.config, + event_names: values.event_names, + model_markups: values.model_markups, + }; - const validationError = validateCreditSystem(creditSystem); - if (validationError) { - toast.error(validationError); - return; - } + const validationError = validateCreditSystem(creditSystem); + if (validationError) { + toast.error(validationError); + return; + } - setLoading(true); - try { await FeatureService.updateFeature( axiosInstance, selectedCreditSystem.id, { - id: creditSystem.id, - name: creditSystem.name, - type: creditSystem.type, - credit_schema: creditSystem.config?.schema?.map( - (x: CreditSchemaItem) => ({ - metered_feature_id: x.metered_feature_id, - credit_cost: Number(x.credit_amount), - }), - ), - event_names: creditSystem.event_names, + id: values.id, + name: values.name, + type: values.type, + ...buildFeatureMarkupParams({ + type: values.type, + modelMarkups: values.model_markups, + defaultMarkup: values.defaultMarkup, + providerMarkups: values.provider_markups, + schema: values.config?.schema as CreditSchemaItem[] | undefined, + }), + event_names: values.event_names, display: undefined, }, ); await refetch(); toast.success("Credit system updated successfully"); - - // Call onSuccess with old and new IDs - if (onSuccess) { - onSuccess( - selectedCreditSystem.id, - creditSystem.id || selectedCreditSystem.id, - ); - } - - setOpen(false); - } catch (error: unknown) { - console.log(error); - toast.error( - getBackendErr(error as AxiosError, "Failed to update credit system"), + onSuccess?.( + selectedCreditSystem.id, + values.id || selectedCreditSystem.id, ); - } finally { - setLoading(false); - } - }; + setOpen(false); + }, + }); - const handleCancel = () => { - setOpen(false); - }; + const isSubmitting = useStore(form.store, (s) => s.isSubmitting); return ( - +
- - + +
setOpen(false)} singleShortcut="escape" > Cancel + form.handleSubmit().catch((err: AxiosError) => { + toast.error( + getBackendErr(err, "Failed to update credit system"), + ); + }) + } metaShortcut="enter" - isLoading={loading} + isLoading={isSubmitting} > Update credit system diff --git a/vite/src/views/products/features/credit-systems/hooks/useAiProviders.ts b/vite/src/views/products/features/credit-systems/hooks/useAiProviders.ts new file mode 100644 index 000000000..8c511fbb6 --- /dev/null +++ b/vite/src/views/products/features/credit-systems/hooks/useAiProviders.ts @@ -0,0 +1,130 @@ +import { + joinModelId, + type ModelsDevProvider, + splitModelId, +} from "@autumn/shared"; +import { useStore } from "@tanstack/react-form"; +import { useMemo } from "react"; +import { useModelsDevPricing } from "@/hooks/queries/useAiModelsQuery"; +import { addCustomModelMarkup } from "../utils/modelMarkupUtils"; +import type { CreditSystemFormInstance } from "./useCreditSystemForm"; + +function groupByProvider(markups: Record) { + const groups: Record = {}; + for (const fullId of Object.keys(markups)) { + const { provider } = splitModelId(fullId); + if (!provider) continue; + const group = groups[provider] ?? []; + group.push(fullId); + groups[provider] = group; + } + return groups; +} + +export function useAiProviders(form: CreditSystemFormInstance) { + const { providers, isLoading } = useModelsDevPricing(); + const modelMarkups = useStore(form.store, (s) => s.values.model_markups); + const defaultMarkup = useStore(form.store, (s) => s.values.defaultMarkup); + const providerMarkups = useStore( + form.store, + (s) => s.values.provider_markups, + ); + + const providerGroups = useMemo( + () => groupByProvider(modelMarkups), + [modelMarkups], + ); + // A provider is "active" if it has model overrides OR a provider-level markup. + const activeProviderKeys = useMemo( + () => + Array.from( + new Set([ + ...Object.keys(providerGroups), + ...Object.keys(providerMarkups), + ]), + ), + [providerGroups, providerMarkups], + ); + + const availableProviders = useMemo(() => { + const filtered = Object.values(providers).filter( + (p) => !activeProviderKeys.includes(p.id), + ); + if (!activeProviderKeys.includes("custom")) { + filtered.push({ + id: "custom", + name: "Custom", + models: {}, + } as ModelsDevProvider); + } + return filtered; + }, [providers, activeProviderKeys]); + + const addProvider = (providerKey: string) => { + form.setFieldValue("model_markups", (prev) => { + if (providerKey === "custom") { + return addCustomModelMarkup(prev); + } + const provider = providers[providerKey]; + if (!provider) return prev; + const firstKey = Object.keys(provider.models)[0]; + if (!firstKey) return prev; + return { ...prev, [joinModelId(providerKey, firstKey)]: {} }; + }); + }; + + const removeKeys = (keys: string[]) => + form.setFieldValue("model_markups", (prev) => { + const updated = { ...prev }; + for (const k of keys) delete updated[k]; + return updated; + }); + + const setProviderMarkup = (providerKey: string, value: number | undefined) => + form.setFieldValue("provider_markups", (prev) => { + const updated = { ...prev }; + if (value == null) { + delete updated[providerKey]; + } else { + updated[providerKey] = { markup: value }; + } + return updated; + }); + + // Removes the whole provider section: all its model overrides and its markup. + const removeProvider = (providerKey: string) => { + form.setFieldValue("model_markups", (prev) => { + const updated = { ...prev }; + for (const k of Object.keys(updated)) { + if (splitModelId(k).provider === providerKey) delete updated[k]; + } + return updated; + }); + setProviderMarkup(providerKey, undefined); + }; + + const renameKey = (oldKey: string, newKey: string) => + form.setFieldValue("model_markups", (prev) => { + if (newKey in prev) return prev; + const updated = { ...prev }; + const entry = updated[oldKey]; + delete updated[oldKey]; + updated[newKey] = { ...entry }; + return updated; + }); + + return { + providers, + isLoading, + defaultMarkup, + providerMarkups, + providerGroups, + activeProviderKeys, + availableProviders, + addProvider, + removeKeys, + removeProvider, + setProviderMarkup, + renameKey, + }; +} diff --git a/vite/src/views/products/features/credit-systems/hooks/useCreditSchema.ts b/vite/src/views/products/features/credit-systems/hooks/useCreditSchema.ts new file mode 100644 index 000000000..169eb12dd --- /dev/null +++ b/vite/src/views/products/features/credit-systems/hooks/useCreditSchema.ts @@ -0,0 +1,70 @@ +import type { CreditSchemaItem, Feature } from "@autumn/shared"; +import { FeatureType, isAiCreditSystem } from "@autumn/shared"; +import { useStore } from "@tanstack/react-form"; +import { useMemo, useRef } from "react"; +import { toast } from "sonner"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import type { CreditSystemFormInstance } from "./useCreditSystemForm"; + +export function useCreditSchema(form: CreditSystemFormInstance) { + const { features } = useFeaturesQuery(); + const config = useStore(form.store, (s) => s.values.config); + const schema: CreditSchemaItem[] = config?.schema || []; + + const schemaKeysRef = useRef([]); + const schemaKeys = useMemo(() => { + const nextKeys = [...schemaKeysRef.current]; + while (nextKeys.length < schema.length) nextKeys.push(crypto.randomUUID()); + while (nextKeys.length > schema.length) nextKeys.pop(); + schemaKeysRef.current = nextKeys; + return nextKeys; + }, [schema.length]); + + const allSchemaCandidateFeatures = features.filter( + (f: Feature) => + f.type === FeatureType.Metered || isAiCreditSystem(f.type), + ); + + const handleSchemaChange = ( + index: number, + key: keyof CreditSchemaItem, + value: string | number, + ) => { + const newSchema = [...schema]; + newSchema[index] = { ...newSchema[index], [key]: value }; + form.setFieldValue("config", { ...config, schema: newSchema }); + }; + + const addSchemaItem = () => { + schemaKeysRef.current = [...schemaKeysRef.current, crypto.randomUUID()]; + form.setFieldValue("config", { + ...config, + schema: [ + ...schema, + { metered_feature_id: "", feature_amount: 1, credit_amount: 0 }, + ], + }); + }; + + const removeSchemaItem = (index: number) => { + if (schema.length === 1) { + toast.error("There must be at least one item in the credit system"); + return; + } + const nextKeys = [...schemaKeysRef.current]; + nextKeys.splice(index, 1); + schemaKeysRef.current = nextKeys; + const newSchema = [...schema]; + newSchema.splice(index, 1); + form.setFieldValue("config", { ...config, schema: newSchema }); + }; + + return { + schema, + schemaKeys, + allSchemaCandidateFeatures, + handleSchemaChange, + addSchemaItem, + removeSchemaItem, + }; +} diff --git a/vite/src/views/products/features/credit-systems/hooks/useCreditSystemForm.ts b/vite/src/views/products/features/credit-systems/hooks/useCreditSystemForm.ts new file mode 100644 index 000000000..404ab9b67 --- /dev/null +++ b/vite/src/views/products/features/credit-systems/hooks/useCreditSystemForm.ts @@ -0,0 +1,67 @@ +import type { Feature, ModelMarkups } from "@autumn/shared"; +import { FeatureType } from "@autumn/shared"; +import { useStore } from "@tanstack/react-form"; +import { useEffect, useRef } from "react"; +import { useAppForm } from "@/hooks/form/form"; + +export interface CreditSystemFormValues { + name: string; + id: string; + type: FeatureType; + config: Record; + event_names: string[]; + model_markups: NonNullable; + /** Global default markup for the AI credit system (persisted to config.default_markup). */ + defaultMarkup: number; + /** Per-provider default markups (persisted to config.provider_markups). */ + provider_markups: Record; +} + +export function useCreditSystemForm({ + feature, + onSubmit, + onChange, +}: { + feature: Feature | null; + onSubmit?: (values: CreditSystemFormValues) => Promise; + onChange?: (values: CreditSystemFormValues) => void; +}) { + const form = useAppForm({ + defaultValues: { + name: feature?.name ?? "", + id: feature?.id ?? "", + type: feature?.type ?? FeatureType.CreditSystem, + config: feature?.config ?? { + schema: [ + { metered_feature_id: "", feature_amount: 1, credit_amount: 0 }, + ], + }, + event_names: feature?.event_names ?? [], + model_markups: + (feature?.model_markups as CreditSystemFormValues["model_markups"]) ?? + {}, + defaultMarkup: + (feature?.config?.default_markup as number | undefined) ?? 0, + provider_markups: + (feature?.config + ?.provider_markups as CreditSystemFormValues["provider_markups"]) ?? + {}, + } satisfies CreditSystemFormValues, + onSubmit: onSubmit ? ({ value }) => onSubmit(value) : undefined, + }); + + // Form-level `listeners.onChange` only fires when a FieldApi instance is + // registered for the changed field (see form-core FormApi.setFieldValue). + // None of these fields are mounted via , so we subscribe to the + // store directly and push value changes out to the caller. + const values = useStore(form.store, (s) => s.values); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + useEffect(() => { + onChangeRef.current?.(values); + }, [values]); + + return form; +} + +export type CreditSystemFormInstance = ReturnType; diff --git a/vite/src/views/products/features/credit-systems/hooks/useProviderMarkup.ts b/vite/src/views/products/features/credit-systems/hooks/useProviderMarkup.ts new file mode 100644 index 000000000..54013bfaf --- /dev/null +++ b/vite/src/views/products/features/credit-systems/hooks/useProviderMarkup.ts @@ -0,0 +1,21 @@ +import { resolveInheritedMarkup } from "@autumn/shared"; +import { useStore } from "@tanstack/react-form"; +import type { CreditSystemFormInstance } from "./useCreditSystemForm"; + +/** Centralizes the default/provider markup store selectors and their inherited-markup resolution for a single provider. */ +export const useProviderMarkup = ( + form: CreditSystemFormInstance, + providerKey: string, +) => { + const defaultMarkup = useStore(form.store, (s) => s.values.defaultMarkup); + const providerMarkup = useStore( + form.store, + (s) => s.values.provider_markups[providerKey]?.markup, + ); + const inheritedMarkup = resolveInheritedMarkup({ + providerMarkup, + defaultMarkup, + }); + + return { defaultMarkup, providerMarkup, inheritedMarkup }; +}; diff --git a/vite/src/views/products/features/credit-systems/utils/modelMarkupUtils.ts b/vite/src/views/products/features/credit-systems/utils/modelMarkupUtils.ts new file mode 100644 index 000000000..faf2fb7cd --- /dev/null +++ b/vite/src/views/products/features/credit-systems/utils/modelMarkupUtils.ts @@ -0,0 +1,24 @@ +import { + CUSTOM_PROVIDER, + isCustomModel, + joinModelId, + type ModelMarkups, +} from "@autumn/shared"; + +type ModelMarkupMap = NonNullable; + +/** Append a blank custom-model row, choosing the next free `custom/model-N` key. */ +export const addCustomModelMarkup = (prev: ModelMarkupMap): ModelMarkupMap => { + const existing = Object.keys(prev).filter((key) => isCustomModel(key)); + let index = 1; + while (existing.includes(joinModelId(CUSTOM_PROVIDER, `model-${index}`))) { + index++; + } + return { + ...prev, + [joinModelId(CUSTOM_PROVIDER, `model-${index}`)]: { + input_cost: 0, + output_cost: 0, + }, + }; +}; diff --git a/vite/src/views/products/features/credit-systems/utils/validateCreditSystem.ts b/vite/src/views/products/features/credit-systems/utils/validateCreditSystem.ts index d470bc254..d45d278b6 100644 --- a/vite/src/views/products/features/credit-systems/utils/validateCreditSystem.ts +++ b/vite/src/views/products/features/credit-systems/utils/validateCreditSystem.ts @@ -1,4 +1,9 @@ -import type { CreateFeature } from "@autumn/shared"; +import { + type CreateFeature, + isAiCreditSystem, + isCustomModel, + splitModelId, +} from "@autumn/shared"; export const validateCreditSystem = ( creditSystem: CreateFeature, @@ -7,16 +12,32 @@ export const validateCreditSystem = ( return "Please fill in all fields"; } - if (creditSystem.config.schema.length === 0) { - return "Need at least one metered feature"; + if (isAiCreditSystem(creditSystem.type)) { + // No per-model rows is valid: such systems bill at the base cost, + // adjusted by any provider-level or global default markup. + for (const [modelId, entry] of Object.entries( + creditSystem.model_markups ?? {}, + )) { + if (!modelId) return "Select a model for each row"; + if (isCustomModel(modelId)) { + const { modelKey } = splitModelId(modelId); + if (!modelKey) return "Custom model ID cannot be empty"; + if (entry.input_cost == null || entry.output_cost == null) + return "Custom models require input and output costs"; + } + } + return null; + } + + if (!creditSystem.config?.schema || creditSystem.config.schema.length === 0) { + return "Need at least one item in the schema"; } for (const item of creditSystem.config.schema) { if (!item.metered_feature_id) { - return "Select a metered feature"; + return "Select a feature for each row"; } - - if (item.feature_amount <= 0 || item.credit_amount <= 0) { + if ((item.credit_amount ?? 0) <= 0) { return "Credit amount must be greater than 0"; } } diff --git a/vite/src/views/products/features/feature-list/CreditListColumns.tsx b/vite/src/views/products/features/feature-list/CreditListColumns.tsx index b6955c648..0a7abf6a1 100644 --- a/vite/src/views/products/features/feature-list/CreditListColumns.tsx +++ b/vite/src/views/products/features/feature-list/CreditListColumns.tsx @@ -1,11 +1,28 @@ -import type { Feature } from "@autumn/shared"; +import { + type Feature, + isAiCreditSystem, + type ModelsDevProvider, + splitModelId, +} from "@autumn/shared"; +import { CoinsIcon, CpuIcon } from "@phosphor-icons/react"; import type { ColumnDef, Row } from "@tanstack/react-table"; import { AdminHover } from "@/components/general/AdminHover"; import { MiniCopyButton } from "@/components/v2/buttons/CopyButton"; import { getFeatureHoverTexts } from "@/views/admin/adminUtils"; import { FeatureListRowToolbar } from "./FeatureListRowToolbar"; -export const createCreditListColumns = (): ColumnDef[] => [ +function resolveModelName( + fullId: string, + providers: Record, +): string { + const { provider, modelKey } = splitModelId(fullId); + if (!provider) return fullId; + return providers[provider]?.models[modelKey]?.name ?? fullId; +} + +export const createCreditListColumns = ( + providers: Record, +): ColumnDef[] => [ { size: 150, header: "Name", @@ -37,21 +54,53 @@ export const createCreditListColumns = (): ColumnDef[] => [ ); }, }, + { + header: "Type", + size: 160, + accessorKey: "type", + cell: ({ row }: { row: Row }) => { + const isAi = isAiCreditSystem(row.original.type); + return ( +
+ {isAi ? ( + <> + + AI Credit System + + ) : ( + <> + + Credit System + + )} +
+ ); + }, + }, { header: "Features", - size: 250, + size: 200, accessorKey: "features", cell: ({ row }: { row: Row }) => { const creditSystem = row.original; + const modelMarkupEntries = creditSystem.model_markups + ? Object.entries(creditSystem.model_markups) + : null; const featureIds = - creditSystem.config?.schema - ?.map( - (schema: { metered_feature_id: string }) => - schema.metered_feature_id, - ) - .join(", ") || "—"; + modelMarkupEntries && modelMarkupEntries.length > 0 + ? modelMarkupEntries + .map(([fullId]) => resolveModelName(fullId, providers)) + .join(", ") + : creditSystem.config?.schema + ?.map( + (schema: { metered_feature_id: string }) => + schema.metered_feature_id, + ) + .join(", ") || "—"; return ( -
{featureIds}
+
+ {featureIds} +
); }, }, diff --git a/vite/src/views/products/features/feature-list/FeatureListTable.tsx b/vite/src/views/products/features/feature-list/FeatureListTable.tsx index a9aca0662..8d65826a6 100644 --- a/vite/src/views/products/features/feature-list/FeatureListTable.tsx +++ b/vite/src/views/products/features/feature-list/FeatureListTable.tsx @@ -1,9 +1,10 @@ -import { AppEnv, type Feature, FeatureType } from "@autumn/shared"; +import { AppEnv, type Feature, isAnyCreditSystem } from "@autumn/shared"; import { ArrowSquareOutIcon, CoinsIcon, LegoIcon } from "@phosphor-icons/react"; import { useMemo, useState } from "react"; import { Table } from "@/components/general/table"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { EmptyState } from "@/components/v2/empty-states/EmptyState"; +import { useModelsDevPricing } from "@/hooks/queries/useAiModelsQuery"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { useEnv } from "@/utils/envUtils"; import { useProductsQueryState } from "@/views/products/hooks/useProductsQueryState"; @@ -18,6 +19,7 @@ import { FeatureListMenuButton } from "./FeatureListMenuButton"; export function FeatureListTable() { const env = useEnv(); const { features } = useFeaturesQuery(); + const { providers } = useModelsDevPricing(); const { queryStates } = useProductsQueryState(); const [selectedFeature, setSelectedFeature] = useState(null); const [updateFeatureOpen, setUpdateFeatureOpen] = useState(false); @@ -28,14 +30,14 @@ export function FeatureListTable() { // Filter features and credit systems based on archived state const { regularFeatures, creditSystems, hasEventNames } = useMemo(() => { const regularFeatures = features?.filter((feature) => { - if (feature.type === FeatureType.CreditSystem) return false; + if (isAnyCreditSystem(feature.type)) return false; return queryStates.showArchivedFeatures ? feature.archived : !feature.archived; }); const creditSystems = features?.filter((feature) => { - if (feature.type !== FeatureType.CreditSystem) return false; + if (!isAnyCreditSystem(feature.type)) return false; return queryStates.showArchivedFeatures ? feature.archived : !feature.archived; @@ -52,7 +54,10 @@ export function FeatureListTable() { () => createFeatureListColumns({ showEventNames: hasEventNames }), [hasEventNames], ); - const creditColumns = useMemo(() => createCreditListColumns(), []); + const creditColumns = useMemo( + () => createCreditListColumns(providers), + [providers], + ); const featureTable = useProductTable({ data: regularFeatures || [], diff --git a/vite/src/views/products/features/utils/buildFeatureMutationParams.ts b/vite/src/views/products/features/utils/buildFeatureMutationParams.ts new file mode 100644 index 000000000..6a1e6a89e --- /dev/null +++ b/vite/src/views/products/features/utils/buildFeatureMutationParams.ts @@ -0,0 +1,49 @@ +import { + type CreditSchemaItem, + type FeatureType, + isAiCreditSystem, + type ModelMarkups, + type ProviderMarkups, +} from "@autumn/shared"; + +interface BuildFeatureMarkupParamsArgs { + type: FeatureType; + modelMarkups?: ModelMarkups; + defaultMarkup?: number | null; + providerMarkups?: ProviderMarkups; + schema?: CreditSchemaItem[]; +} + +interface FeatureMarkupParams { + model_markups?: ModelMarkups; + default_markup?: number | null; + provider_markups?: ProviderMarkups; + credit_schema?: { metered_feature_id: string; credit_cost: number }[]; +} + +/** + * Centralizes the AI-vs-classic credit system field selection shared by the + * feature mutation sheets. AI credit systems carry markup fields and omit the + * credit schema; classic credit systems do the inverse. + */ +export const buildFeatureMarkupParams = ({ + type, + modelMarkups, + defaultMarkup, + providerMarkups, + schema, +}: BuildFeatureMarkupParamsArgs): FeatureMarkupParams => { + const ai = isAiCreditSystem(type); + return { + model_markups: ai ? modelMarkups : undefined, + default_markup: ai ? defaultMarkup : undefined, + provider_markups: ai ? providerMarkups : undefined, + credit_schema: ai + ? undefined + : schema?.map((item) => ({ + metered_feature_id: item.metered_feature_id, + credit_cost: + item.credit_amount != null ? Number(item.credit_amount) : 0, + })), + }; +}; diff --git a/vite/src/views/products/features/utils/getFeatureIcon.tsx b/vite/src/views/products/features/utils/getFeatureIcon.tsx index 264e4c86c..f1d958cde 100644 --- a/vite/src/views/products/features/utils/getFeatureIcon.tsx +++ b/vite/src/views/products/features/utils/getFeatureIcon.tsx @@ -2,11 +2,13 @@ import type { Feature, ProductItem } from "@autumn/shared"; import { FeatureType, FeatureUsageType, + isAiCreditSystem, ProductItemFeatureType, } from "@autumn/shared"; import { BatteryHighIcon, CoinsIcon, + CpuIcon, TicketIcon, ToggleRightIcon, } from "@phosphor-icons/react"; @@ -68,6 +70,15 @@ export const getFeatureIconConfig = ( }; } + // Handle AI credit system + if (isAiCreditSystem(typeStr) || typeStr === "ai_credit_system") { + return { + icon: , + color: "text-yellow-500", + label: "AI Credit System", + }; + } + // Handle credit system if (typeStr === FeatureType.CreditSystem || typeStr === "credit_system") { return { 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 784a521cd..359203b4c 100644 --- a/vite/src/views/products/plan/ProductSheets.tsx +++ b/vite/src/views/products/plan/ProductSheets.tsx @@ -23,6 +23,7 @@ export const ProductSheets = () => { itemId, initialItem, setInitialItem, + updateItemId, closeSheet, itemDraft, } = useSheet(); @@ -121,6 +122,15 @@ export const ProductSheets = () => { if (!product || !product.items || resolvedItemIndex === -1) return; + const newItemId = getItemId({ + item: updatedItem, + itemIndex: resolvedItemIndex, + }); + if (newItemId !== itemId) { + updateItemId(newItemId); + lastItemIdRef.current = newItemId; + } + const updatedItems = [...product.items]; updatedItems[resolvedItemIndex] = updatedItem; setProduct({ ...product, items: updatedItems }); 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/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/BillingType.tsx b/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx index 91d08183f..62ffb5d49 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx @@ -3,6 +3,7 @@ import { FeatureUsageType, getFeatureName, Infinite, + isAiCreditSystem, isContUseItem, isFeaturePriceItem, ProductItemInterval, @@ -104,7 +105,9 @@ export function BillingType() {
Included
- {isConsumable + {isAiCreditSystem(feature?.type) + ? "Set an included USD budget (eg, $10 per month)." + : isConsumable ? `Set an included usage limit (eg, 100 ${featureName} per month).` : isAllocated ? `Set a usage limit (eg, 5 ${featureName}).` @@ -124,7 +127,9 @@ export function BillingType() {
Priced
- {isConsumable + {isAiCreditSystem(feature?.type) + ? "Bill model usage at the markup you set in USD after the included budget is used." + : isConsumable ? `Charge a price for usage (eg, $0.05 per ${singleFeatureName}).` : isAllocated ? `Charge a price based on usage (eg, $10 per ${singleFeatureName}).` 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 d3982aaf8..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 @@ -1,4 +1,4 @@ -import { FeatureType, TierBehavior } from "@autumn/shared"; +import { FeatureType, isAiCreditSystem, TierBehavior } from "@autumn/shared"; import { PencilSimpleIcon } from "@phosphor-icons/react"; import { useState } from "react"; import { IconButton } from "@/components/v2/buttons/IconButton"; @@ -170,7 +170,7 @@ export function EditPlanFeatureSheet({ - {isFeaturePrice && ( + {isFeaturePrice && !isAiCreditSystem(feature?.type) && ( 1 ? ( diff --git a/vite/src/views/products/plan/components/edit-plan-feature/IncludedUsage.tsx b/vite/src/views/products/plan/components/edit-plan-feature/IncludedUsage.tsx index 1daaa82fd..16ece32b8 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/IncludedUsage.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/IncludedUsage.tsx @@ -5,11 +5,17 @@ import { entToItemInterval, getFeatureName, Infinite, + isAiCreditSystem, isContUseItem, } from "@autumn/shared"; import { InfinityIcon } from "@phosphor-icons/react"; import { IconCheckbox } from "@/components/v2/checkboxes/IconCheckbox"; import { Input } from "@/components/v2/inputs/Input"; +import { + InputGroup, + InputGroupInput, + InputGroupText, +} from "@/components/v2/inputs/InputGroup"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { isFeaturePriceItem } from "@/utils/product/getItemType"; import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext"; @@ -24,11 +30,12 @@ export function IncludedUsage() { const includedUsage = item.included_usage; const isFeaturePrice = isFeaturePriceItem(item); + const feature = features.find((f) => f.id === item.feature_id); // Helper function to get the display value for the input const getInputValue = () => { if (includedUsage === Infinite) { - return "Unlimited"; + return ""; } if (includedUsage === null || includedUsage === undefined) { return ""; @@ -41,35 +48,67 @@ export function IncludedUsage() {
- Quantity of  - - {getFeatureName({ - feature: features.find((f) => f.id === item.feature_id), - plural: true, - })}{" "} - - {!isFeaturePrice ? " that can be used" : " granted before billing"} + {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"} + + )}
- { - const value = e.target.value.trim(); - - if (value === "") { - setItem({ ...item, included_usage: null }); - } else { - const numValue = value; - if (!Number.isNaN(numValue)) { - setItem({ ...item, included_usage: Number(numValue) }); + {isAiCreditSystem(feature?.type) ? ( + + $ + + value={getInputValue()} + onChange={(e) => { + const value = e.target.value.trim(); + + if (value === "") { + setItem({ ...item, included_usage: null }); + } else { + const numValue = Number(value); + if (!Number.isNaN(numValue)) { + setItem({ ...item, included_usage: numValue }); + } + } + }} + disabled={includedUsage === Infinite} + type="number" + /> + + ) : ( + { + const value = e.target.value.trim(); + + if (value === "") { + setItem({ ...item, included_usage: null }); + } else { + const numValue = Number(value); + if (!Number.isNaN(numValue)) { + setItem({ ...item, included_usage: numValue }); + } + } + }} + disabled={includedUsage === Infinite} + type="number" + /> + )} } 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/NewFeatureBehaviour.tsx b/vite/src/views/products/plan/components/new-feature/NewFeatureBehaviour.tsx index a3cba3ac6..64292d89e 100644 --- a/vite/src/views/products/plan/components/new-feature/NewFeatureBehaviour.tsx +++ b/vite/src/views/products/plan/components/new-feature/NewFeatureBehaviour.tsx @@ -2,11 +2,56 @@ import { type CreateFeature, FeatureType, FeatureUsageType, + isAiCreditSystem, + isAnyCreditSystem, } from "@autumn/shared"; import { AreaRadioGroupItem } from "@/components/v2/radio-groups/AreaRadioGroupItem"; import { RadioGroup } from "@/components/v2/radio-groups/RadioGroup"; import { SheetSection } from "@/components/v2/sheets/InlineSheet"; import { CreditSystemSchema } from "@/views/products/features/credit-systems/components/CreditSystemSchema"; +import { useCreditSystemForm } from "@/views/products/features/credit-systems/hooks/useCreditSystemForm"; + +function NewFeatureCreditSchema({ + feature, + setFeature, +}: { + feature: CreateFeature; + setFeature: (feature: CreateFeature) => void; +}) { + const form = useCreditSystemForm({ + feature: { + internal_id: "", + org_id: "", + created_at: 0, + env: "sandbox" as any, + id: feature.id ?? "", + name: feature.name ?? "", + type: feature.type, + config: feature.config ?? {}, + archived: false, + event_names: feature.event_names ?? [], + model_markups: feature.model_markups ?? null, + }, + onChange: (values) => { + const isAi = isAiCreditSystem(values.type); + + setFeature({ + ...feature, + type: values.type, + config: isAi + ? { + ...values.config, + default_markup: values.defaultMarkup, + provider_markups: values.provider_markups, + } + : values.config, + model_markups: values.model_markups, + }); + }, + }); + + return ; +} export function NewFeatureBehaviour({ feature, @@ -15,10 +60,8 @@ export function NewFeatureBehaviour({ feature: CreateFeature; setFeature: (feature: CreateFeature) => void; }) { - if (feature.type === FeatureType.CreditSystem) { - return ( - - ); + if (isAnyCreditSystem(feature.type)) { + return ; } if (feature.type === FeatureType.Metered) { 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/DummyPlanFeatureRow.tsx b/vite/src/views/products/plan/components/plan-card/DummyPlanFeatureRow.tsx index 13a045e96..78e184def 100644 --- a/vite/src/views/products/plan/components/plan-card/DummyPlanFeatureRow.tsx +++ b/vite/src/views/products/plan/components/plan-card/DummyPlanFeatureRow.tsx @@ -1,4 +1,4 @@ -import { FeatureType, FeatureUsageType } from "@autumn/shared"; +import { FeatureType, FeatureUsageType, isAiCreditSystem } from "@autumn/shared"; import { BoxArrowDownIcon } from "@phosphor-icons/react"; import { useFeatureStore } from "@/hooks/stores/useFeatureStore"; import { cn } from "@/lib/utils"; @@ -48,6 +48,10 @@ export const DummyPlanFeatureRow = () => { const getDisplayText = () => { const name = hasName ? featureName : getPlaceholderName(); + if (isAiCreditSystem(feature.type)) { + return { primary: `$10.00 of ${name}`, secondary: "" }; + } + if (featureType === FeatureType.CreditSystem) { return { primary: `100 ${name}`, secondary: "" }; } diff --git a/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx b/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx index 70eac9e29..a61e44c65 100644 --- a/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx +++ b/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx @@ -1,6 +1,6 @@ /** biome-ignore-all lint/a11y/noStaticElementInteractions: needed */ /** biome-ignore-all lint/a11y/useSemanticElements: needed */ -import type { ProductItem } from "@autumn/shared"; +import { type ProductItem, isAiCreditSystem } from "@autumn/shared"; import { getProductItemDisplay } from "@autumn/shared"; import { TrashIcon } from "@phosphor-icons/react"; import { useEffect, useRef, useState } from "react"; @@ -68,6 +68,7 @@ export const PlanFeatureRow = ({ const feature = features.find((f) => f.id === item.feature_id); const hasFeatureName = feature?.name && feature.name.trim() !== ""; + const displayText = hasFeatureName ? display.primary_text : "Name your feature"; @@ -159,8 +160,9 @@ export const PlanFeatureRow = ({ {...(isDisabled && { "data-disabled": true })} data-pressed={isPressed} className={cn( - "flex items-center w-full group h-10! group/row select-none rounded-xl hover:relative hover:z-95", - "input-base input-state-open-tiny", + "flex items-center w-full group group/row select-none rounded-xl hover:relative hover:z-95", + !readOnly && "h-10! input-base input-state-open-tiny", + readOnly && "py-1", isDisabled && "pointer-events-none cursor-default", isSelected && "border-transparent z-95 relative bg-interative-secondary outline-4! outline-outer-background!", @@ -205,7 +207,9 @@ export const PlanFeatureRow = ({ {displayText} - {display.secondary_text} + {!isAiCreditSystem(feature?.type) && display.secondary_text && ( + {display.secondary_text} + )}

{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/tests/views/migrations/migration/filters/filterRowTypes.test.ts b/vite/tests/views/migrations/migration/filters/filterRowTypes.test.ts index 4e2aff01c..74e64c9dd 100644 --- a/vite/tests/views/migrations/migration/filters/filterRowTypes.test.ts +++ b/vite/tests/views/migrations/migration/filters/filterRowTypes.test.ts @@ -58,6 +58,14 @@ describe("planFilterToGroups -> groupsToPlanFilter roundtrip", () => { expect(roundtrip({ recurring: true })).toEqual({ recurring: true }); }); + test("custom: true", () => { + expect(roundtrip({ custom: true })).toEqual({ custom: true }); + }); + + test("custom: false", () => { + expect(roundtrip({ custom: false })).toEqual({ custom: false }); + }); + test("price: null (free plan)", () => { expect(roundtrip({ price: null })).toEqual({ price: null }); }); @@ -73,48 +81,13 @@ describe("planFilterToGroups -> groupsToPlanFilter roundtrip", () => { expect(roundtrip(input)).toEqual(input); }); - test("item with $every wrapper", () => { - const input: PlanFilter = { - item: { $every: { feature_id: "credits" } }, - }; - expect(roundtrip(input)).toEqual(input); - }); - - test("item with $none wrapper", () => { - const input: PlanFilter = { - item: { $none: { feature_id: "credits" } }, - }; - expect(roundtrip(input)).toEqual(input); - }); - test("item unlimited boolean", () => { const input: PlanFilter = { item: { unlimited: true } }; expect(roundtrip(input)).toEqual(input); }); - test("item price null (free item)", () => { - const input: PlanFilter = { item: { price: null } }; - expect(roundtrip(input)).toEqual(input); - }); - - test("item price { $ne: null } (paid item)", () => { - const input: PlanFilter = { item: { price: { $ne: null } } }; - expect(roundtrip(input)).toEqual(input); - }); - - test("item billing_method", () => { - const input: PlanFilter = { - item: { price: { billing_method: "prepaid" } }, - }; - const result = roundtrip(input); - expect(result.item).toBeDefined(); - const price = (result.item as Record).price as Record; - expect(price.billing_method).toBe("prepaid"); - }); - test("$or groups", () => { const input: PlanFilter = { - plan_id: "pro", $or: [{ plan_id: "enterprise" }, { plan_id: "team" }], }; expect(roundtrip(input)).toEqual(input); @@ -140,27 +113,38 @@ describe("planFilterToGroups structure", () => { test("$or produces multiple groups", () => { const groups = planFilterToGroups({ - plan_id: "a", - $or: [{ plan_id: "b" }], + $or: [{ plan_id: "a" }, { plan_id: "b" }], }); expect(groups).toHaveLength(2); }); - test("$every item mode produces item_mode rule", () => { - const groups = planFilterToGroups({ - item: { $every: { feature_id: "x" } }, +}); + +describe("groupsToPlanFilter", () => { + test("serializes visual OR groups as top-level $or branches", () => { + expect( + groupsToPlanFilter([ + { + rules: [{ field: "custom", operator: "is", values: ["true"] }], + }, + { + rules: [{ field: "custom", operator: "is", values: ["false"] }], + }, + ]), + ).toEqual({ + $or: [{ custom: true }, { custom: false }], }); - const modeRule = groups[0].rules.find((r) => r.field === "item_mode"); - expect(modeRule).toBeDefined(); - expect(modeRule!.values).toEqual(["every"]); }); - test("implicit $some does not produce item_mode rule", () => { - const groups = planFilterToGroups({ - item: { feature_id: "x" }, - }); - const modeRule = groups[0].rules.find((r) => r.field === "item_mode"); - expect(modeRule).toBeUndefined(); + test("ignores empty OR groups", () => { + expect( + groupsToPlanFilter([ + { + rules: [{ field: "custom", operator: "is", values: ["true"] }], + }, + { rules: [{ field: "plan_id", operator: "is", values: [] }] }, + ]), + ).toEqual({ custom: true }); }); }); diff --git a/vite/tests/views/migrations/migration/operations/update-plan-op-form.test.ts b/vite/tests/views/migrations/migration/operations/update-plan-op-form.test.ts new file mode 100644 index 000000000..341a6df6d --- /dev/null +++ b/vite/tests/views/migrations/migration/operations/update-plan-op-form.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import type { UpdatePlanOp } from "@autumn/shared"; +import { getPlanVersionActionLabel } from "@/views/migrations/migration/operations/UpdatePlanOpForm"; + +const op = (patch: Partial): UpdatePlanOp => ({ + type: "update_plan", + plan_filter: {}, + ...patch, +}); + +describe("UpdatePlanOpForm", () => { + test("labels same-version version operations as reset", () => { + expect( + getPlanVersionActionLabel( + op({ plan_filter: { version: 2 }, version: 2 }), + ), + ).toBe("Reset to Plan Version"); + }); + + test("keeps set label when operation version differs from filter version", () => { + expect( + getPlanVersionActionLabel( + op({ plan_filter: { version: 1 }, version: 2 }), + ), + ).toBe("Set Plan Version"); + }); + + test("uses the menu default version before a version is selected", () => { + expect(getPlanVersionActionLabel(op({ plan_filter: { version: 1 } }))).toBe( + "Reset to Plan Version", + ); + }); +}); diff --git a/vite/tests/views/migrations/migration/shared/migration-item-utils.test.ts b/vite/tests/views/migrations/migration/shared/migration-item-utils.test.ts new file mode 100644 index 000000000..d453dfe0c --- /dev/null +++ b/vite/tests/views/migrations/migration/shared/migration-item-utils.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test"; +import { + AppEnv, + BillingMethod, + FeatureType, + FeatureUsageType, + ProductItemInterval, + TierBehavior, + UsageModel, + type Feature, + type ProductItem, +} from "@autumn/shared"; +import { + migrationItemToProductItem, + productItemToMigrationItem, +} from "@/views/migrations/migration/shared/migrationItemUtils"; + +const features: Feature[] = [ + { + internal_id: "fe_credits", + org_id: "org_1", + created_at: 1, + env: AppEnv.Sandbox, + id: "credits", + name: "Credits", + type: FeatureType.Metered, + config: { usage_type: FeatureUsageType.Single }, + display: null, + archived: false, + event_names: [], + }, +]; + +describe("migrationItemUtils", () => { + test("round-trips tiered usage prices", () => { + const migrationItem = { + feature_id: "credits", + included: 0, + price: { + tiers: [ + { to: 100, amount: 20 }, + { to: "inf", amount: 40 }, + ], + tier_behavior: TierBehavior.Graduated, + interval: ProductItemInterval.Month, + billing_units: 1, + billing_method: BillingMethod.UsageBased, + max_purchase: null, + }, + }; + + const productItem = migrationItemToProductItem(migrationItem, features); + + expect(productItem.tiers).toEqual([ + { to: 100, amount: 20 }, + { to: "inf", amount: 40 }, + ]); + expect(productItem.tier_behavior).toBe(TierBehavior.Graduated); + expect(productItem.usage_model).toBe(UsageModel.PayPerUse); + + const saved = productItemToMigrationItem(productItem); + + expect(saved).toMatchObject(migrationItem); + expect(saved.price).not.toHaveProperty("amount"); + }); + + test("adjusts tier bounds by included usage", () => { + const productItem = migrationItemToProductItem( + { + feature_id: "credits", + included: 10, + price: { + tiers: [ + { to: 110, amount: 20 }, + { to: "inf", amount: 40 }, + ], + tier_behavior: TierBehavior.Graduated, + interval: ProductItemInterval.Month, + billing_units: 1, + billing_method: BillingMethod.UsageBased, + max_purchase: 500, + }, + }, + features, + ); + + expect(productItem.tiers).toEqual([ + { to: 100, amount: 20 }, + { to: "inf", amount: 40 }, + ]); + expect(productItem.usage_limit).toBe(510); + + const saved = productItemToMigrationItem(productItem); + expect(saved.price).toMatchObject({ + tiers: [ + { to: 110, amount: 20 }, + { to: "inf", amount: 40 }, + ], + max_purchase: 500, + }); + }); + + test("keeps simple prices in amount form", () => { + const migrationItem = productItemToMigrationItem({ + feature_id: "credits", + included_usage: 0, + interval: ProductItemInterval.Month, + usage_model: UsageModel.PayPerUse, + tiers: [{ to: "inf", amount: 25 }], + billing_units: 1, + } as ProductItem); + + expect(migrationItem.price).toMatchObject({ + amount: 25, + interval: ProductItemInterval.Month, + billing_method: BillingMethod.UsageBased, + }); + expect(migrationItem.price).not.toHaveProperty("tiers"); + }); +}); diff --git a/vite/tests/views/products/plan/versioning/build-in-place-update-plan-params.test.ts b/vite/tests/views/products/plan/versioning/build-in-place-update-plan-params.test.ts new file mode 100644 index 000000000..c0e5c8cfc --- /dev/null +++ b/vite/tests/views/products/plan/versioning/build-in-place-update-plan-params.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test"; +import { + AppEnv, + FeatureType, + FeatureUsageType, + ProductItemInterval, + UpdatePlanParamsV2Schema, + type Feature, + type FrontendProduct, +} from "@autumn/shared"; +import { buildInPlaceUpdatePlanParams } from "@/views/products/plan/versioning/buildMigrationDraft"; + +const features: Feature[] = [ + { + internal_id: "fe_messages", + org_id: "org_1", + created_at: 1, + env: AppEnv.Sandbox, + id: "messages", + name: "Messages", + type: FeatureType.Metered, + config: { usage_type: FeatureUsageType.Single }, + display: null, + archived: false, + event_names: [], + }, + { + internal_id: "fe_admin", + org_id: "org_1", + created_at: 1, + env: AppEnv.Sandbox, + id: "admin", + name: "Admin", + type: FeatureType.Metered, + config: { usage_type: FeatureUsageType.Continuous }, + display: null, + archived: false, + event_names: [], + }, +]; + +const baseProduct: FrontendProduct = { + id: "pro", + name: "Pro", + description: "Old description", + is_add_on: false, + is_default: false, + version: 3, + group: "core", + env: AppEnv.Sandbox, + free_trial: { + duration: "day", + length: 14, + card_required: false, + }, + items: [ + { + price: 10, + interval: "month", + interval_count: 1, + isPrice: true, + }, + ], + created_at: 1, + archived: false, + planType: "paid", + basePriceType: "recurring", +}; + +describe("buildInPlaceUpdatePlanParams", () => { + test("builds a no-version update body for the current plan", () => { + const editedProduct: FrontendProduct = { + ...baseProduct, + name: "Pro Plus", + description: null, + free_trial: null, + items: [ + { + price: 20, + interval: "month", + interval_count: 1, + isPrice: true, + }, + { + feature_id: "messages", + included_usage: 500, + interval: ProductItemInterval.Month, + interval_count: 1, + isPrice: false, + }, + { + feature_id: "admin", + included_usage: 1, + isPrice: false, + }, + ], + }; + + const params = buildInPlaceUpdatePlanParams({ + baseProduct, + editedProduct, + features, + }); + const body = JSON.parse(JSON.stringify(params)); + + expect(body).toMatchObject({ + plan_id: "pro", + version: 3, + name: "Pro Plus", + description: "", + group: "core", + add_on: false, + auto_enable: false, + price: { + amount: 20, + interval: "month", + }, + items: [ + { + feature_id: "admin", + included: 1, + unlimited: false, + }, + { + feature_id: "messages", + included: 500, + unlimited: false, + reset: { interval: "month" }, + }, + ], + free_trial: null, + disable_version: true, + }); + expect(body.items[0]).not.toHaveProperty("reset"); + expect(body.items[0]).not.toHaveProperty("price"); + expect(body.items[1]).not.toHaveProperty("price"); + expect(() => UpdatePlanParamsV2Schema.parse(body)).not.toThrow(); + }); +}); diff --git a/vite/tests/views/products/plan/versioning/build-migration-draft.test.ts b/vite/tests/views/products/plan/versioning/build-migration-draft.test.ts new file mode 100644 index 000000000..f3bd64513 --- /dev/null +++ b/vite/tests/views/products/plan/versioning/build-migration-draft.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, test } from "bun:test"; +import { + AppEnv, + BillingMethod, + FeatureType, + FeatureUsageType, + ProductItemInterval, + TierBehavior, + UsageModel, + type Feature, + type FrontendProduct, + type UpdatePlanOp, +} from "@autumn/shared"; +import { + buildMigrationDraft, + buildVersionMigrationDraft, + type MigrationDraft, +} from "@/views/products/plan/versioning/buildMigrationDraft"; + +const features: Feature[] = [ + { + internal_id: "fe_credits", + org_id: "org_1", + created_at: 1, + env: AppEnv.Sandbox, + id: "credits", + name: "Credits", + type: FeatureType.Metered, + config: { usage_type: FeatureUsageType.Single }, + display: null, + archived: false, + event_names: [], + }, +]; + +const baseProduct: FrontendProduct = { + id: "pro", + name: "Pro", + description: null, + is_add_on: false, + is_default: false, + version: 2, + group: null, + env: AppEnv.Sandbox, + free_trial: null, + items: [], + created_at: 1, + archived: false, + planType: "free", + basePriceType: "free", +}; + +const updatePlanFilters = (draft: MigrationDraft) => + (draft.operations.customer ?? []) + .filter((op): op is UpdatePlanOp => op.type === "update_plan") + .map((op) => op.plan_filter); + +const firstUpdatePlan = (draft: MigrationDraft): UpdatePlanOp => { + const op = draft.operations.customer?.[0]; + if (op?.type === "update_plan") return op; + throw new Error("Expected first migration operation to update a plan"); +}; + +describe("buildMigrationDraft", () => { + test("excludes custom plans by default", () => { + const draft = buildMigrationDraft({ + baseProduct, + editedProduct: { ...baseProduct, name: "Pro updated" }, + features, + scope: "this_version", + }); + + expect(draft.filter.customer?.plan).toMatchObject({ + plan_id: "pro", + version: 2, + custom: false, + }); + expect(firstUpdatePlan(draft).plan_filter).toMatchObject({ + plan_id: "pro", + version: 2, + custom: false, + }); + }); + + test("targets both regular and custom plans when custom plans are included", () => { + const draft = buildMigrationDraft({ + baseProduct, + editedProduct: { ...baseProduct, name: "Pro updated" }, + features, + scope: "this_version", + includeCustom: true, + }); + + expect(draft.filter.customer?.plan).toEqual({ + plan_id: "pro", + version: 2, + }); + expect(updatePlanFilters(draft)).toEqual([ + { + plan_id: "pro", + version: 2, + custom: false, + }, + { + plan_id: "pro", + version: 2, + custom: true, + }, + ]); + }); + + test("keeps custom targeting explicit for version reset migrations", () => { + const draft = buildMigrationDraft({ + baseProduct, + editedProduct: baseProduct, + features, + scope: "this_version", + includeCustom: true, + }); + + expect(updatePlanFilters(draft)).toEqual([ + { + plan_id: "pro", + version: 2, + custom: false, + }, + { + plan_id: "pro", + version: 2, + custom: true, + }, + ]); + }); + + test("keeps a single operation when custom plans are excluded", () => { + const draft = buildMigrationDraft({ + baseProduct, + editedProduct: baseProduct, + features, + scope: "this_version", + }); + + expect(draft.operations.customer).toHaveLength(1); + expect(firstUpdatePlan(draft).plan_filter).toEqual({ + plan_id: "pro", + version: 2, + custom: false, + }); + }); + + test("preserves tiered add-item prices", () => { + const draft = buildMigrationDraft({ + baseProduct, + editedProduct: { + ...baseProduct, + items: [ + { + feature_id: "credits", + included_usage: 0, + interval: ProductItemInterval.Month, + interval_count: 1, + usage_model: UsageModel.PayPerUse, + tiers: [ + { to: 100, amount: 20 }, + { to: "inf", amount: 40 }, + ], + billing_units: 1, + tier_behavior: TierBehavior.Graduated, + }, + ], + }, + features, + scope: "this_version", + }); + + const updatePlan = firstUpdatePlan(draft); + const addItem = updatePlan?.customize?.add_items?.[0]; + const price = JSON.parse(JSON.stringify(addItem?.price)); + + expect(price).toMatchObject({ + tiers: [ + { to: 100, amount: 20 }, + { to: "inf", amount: 40 }, + ], + tier_behavior: TierBehavior.Graduated, + billing_method: BillingMethod.UsageBased, + }); + expect(price).not.toHaveProperty("amount"); + }); +}); + +describe("buildVersionMigrationDraft", () => { + test("excludes custom plans by default", () => { + const draft = buildVersionMigrationDraft({ + productId: "pro", + latestVersion: 3, + scope: "all", + pastVersions: [1, 2], + }); + + expect(draft.filter.customer?.plan).toMatchObject({ + plan_id: "pro", + version: { $in: [1, 2] }, + custom: false, + }); + expect(firstUpdatePlan(draft).plan_filter).toMatchObject({ + plan_id: "pro", + version: { $in: [1, 2] }, + custom: false, + }); + }); + + test("targets both regular and custom versions when custom plans are included", () => { + const draft = buildVersionMigrationDraft({ + productId: "pro", + latestVersion: 3, + scope: 2, + pastVersions: [1, 2], + includeCustom: true, + }); + + expect(draft.filter.customer?.plan).toEqual({ + plan_id: "pro", + version: 2, + }); + expect(updatePlanFilters(draft)).toEqual([ + { plan_id: "pro", version: 2, custom: false }, + { plan_id: "pro", version: 2, custom: true }, + ]); + }); +});