Merge branch 'main' into dev

This commit is contained in:
amianthus
2026-03-28 14:11:17 +00:00
committed by GitHub
117 changed files with 3029 additions and 959 deletions

View File

@@ -99,10 +99,10 @@ jobs:
if echo "$COMMIT_MSG" | grep -qE "^major:|BREAKING CHANGE"; then
echo "bump=major" >> $GITHUB_OUTPUT
echo "Detected major version bump"
elif echo "$COMMIT_MSG" | grep -qE "^minor:|^feat:"; then
elif echo "$COMMIT_MSG" | grep -qE "^minor:"; then
echo "bump=minor" >> $GITHUB_OUTPUT
echo "Detected minor version bump"
elif echo "$COMMIT_MSG" | grep -qE "^fix:|^patch:"; then
elif echo "$COMMIT_MSG" | grep -qE "^fix:|^patch:|^feat:"; then
echo "bump=patch" >> $GITHUB_OUTPUT
echo "Detected patch version bump"
else
@@ -273,6 +273,6 @@ jobs:
echo "No version bump pattern detected in commit message." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "To trigger a publish, use one of these commit message prefixes:" >> $GITHUB_STEP_SUMMARY
echo "- \`fix:\` or \`patch:\` for patch version" >> $GITHUB_STEP_SUMMARY
echo "- \`feat:\` or \`minor:\` for minor version" >> $GITHUB_STEP_SUMMARY
echo "- \`fix:\`, \`patch:\`, or \`feat:\` for patch version" >> $GITHUB_STEP_SUMMARY
echo "- \`minor:\` for minor version" >> $GITHUB_STEP_SUMMARY
echo "- \`major:\` or \`BREAKING CHANGE\` for major version" >> $GITHUB_STEP_SUMMARY

View File

@@ -366,6 +366,10 @@ This is useful for attaching custom metadata to the Stripe subscription created
</Expandable>
</DynamicParamField>
<DynamicParamField body="metadata.{key}" type="string">
Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
</DynamicParamField>
### Response

View File

@@ -299,6 +299,10 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
</Expandable>
</DynamicParamField>
<DynamicParamField body="metadata.{key}" type="string">
Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
</DynamicParamField>
### Response

View File

@@ -269,6 +269,10 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
</Expandable>
</DynamicParamField>
<DynamicParamField body="metadata.{key}" type="string">
Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
</DynamicParamField>
### Response

View File

@@ -157,6 +157,9 @@ When grouping is specified, `values` contains the total sum while `grouped_value
<DynamicParamField body="filter_by.{key}" type="string">
Filter events by property values, e.g. \{"model": "gpt-4", "region": "us"\}. Maximum 5 filters.
</DynamicParamField>
<DynamicParamField body="max_groups" type="integer">
Maximum number of distinct group values to return per time bin when using group_by. Remaining values are bundled into an 'Other' bucket. Defaults to 9
</DynamicParamField>
### Response

View File

@@ -6816,6 +6816,15 @@ paths:
required:
- enabled
description: Whether to carry over usages from the previous plan.
metadata:
type: object
propertyNames:
type: string
additionalProperties:
type: string
description: Key-value metadata to attach to the Stripe subscription, invoice,
and checkout session created during this attach flow. Keys
prefixed with 'autumn_' are reserved and will be stripped.
required:
- customer_id
- plan_id
@@ -7902,6 +7911,15 @@ paths:
required:
- enabled
description: Whether to carry over usages from the previous plan.
metadata:
type: object
propertyNames:
type: string
additionalProperties:
type: string
description: Key-value metadata to attach to the Stripe subscription, invoice,
and checkout session created during this attach flow. Keys
prefixed with 'autumn_' are reserved and will be stripped.
required:
- customer_id
- plan_id
@@ -10802,6 +10820,15 @@ paths:
required:
- enabled
description: Whether to carry over usages from the previous plan.
metadata:
type: object
propertyNames:
type: string
additionalProperties:
type: string
description: Key-value metadata to attach to the Stripe subscription, invoice,
and checkout session created during this attach flow. Keys
prefixed with 'autumn_' are reserved and will be stripped.
required:
- customer_id
title: SetupPaymentParams
@@ -12357,6 +12384,13 @@ paths:
type: string
description: 'Filter events by property values, e.g. {"model": "gpt-4",
"region": "us"}. Maximum 5 filters.'
max_groups:
type: integer
minimum: 1
maximum: 250
description: Maximum number of distinct group values to return per time bin when
using group_by. Remaining values are bundled into an 'Other'
bucket. Defaults to 9
required:
- feature_id
title: EventsAggregateParams

View File

@@ -29,10 +29,6 @@ bunx atmn push -p
Alternatively, the Deploy dialog in the dashboard can copy your sandbox plans to production for you.
<Warning>
Review the diff carefully — production pushes prompt for confirmation before applying changes. Use `--yes` only in CI where you've already validated the config.
</Warning>
</Step>
<Step>
@@ -59,12 +55,6 @@ The key prefix determines the environment automatically — `_test_` routes to s
Autumn's SDK is **fail-open by default** — if Autumn is unreachable, `check`, `track`, and customer fetches return safe dummy responses instead of throwing errors. This means Autumn can never take your app down.
| Method | Fail-open response |
|--------|-------------------|
| `check` | `allowed: true` |
| `track` | Succeeds silently |
| `customers.get` | Empty customer object |
You should verify this before going live. The easiest way is to point the SDK at a non-existent URL and exercise your app's core flows:
<CodeGroup>
@@ -93,24 +83,11 @@ With this in place:
4. Remove the `serverURL` override when done
<Warning>
While the SDK gracefully handles outages for read-path calls, write operations like `attach` (which initiate checkout or subscription changes) will still fail when Autumn is unreachable. This is expected — you don't want to silently skip payment collection.
While the SDK gracefully handles outages for read-path calls, write operations like `attach` (which initiate checkout or subscription changes) will still fail when Autumn is unreachable.
</Warning>
</Step>
<Step>
### Test a real purchase
Make a real purchase using a small-amount plan or Stripe's [test clocks](https://docs.stripe.com/billing/testing/test-clocks):
1. Create a customer with a real email
2. Attach a plan and complete Stripe checkout
3. Verify the subscription appears in both Autumn and your Stripe dashboard
4. Check that `check` and `track` calls work correctly for this customer
5. Cancel the subscription and confirm the customer loses access
</Step>
<Step>
### Set up webhooks (if applicable)

View File

@@ -1,16 +1,3 @@
.dark #topbar-cta-button > a > span {
box-shadow:
0px -13px 25px -5px #6100f299 inset,
0px 3px 15px -5px #6100f2 inset,
0px -2px 6px 0px #b27fff59 inset,
0px 3px 10px 2px #00000040,
0px 2px 0px 1px #661ecfb2 inset !important;
background-color: #1f004d;
border: 1px solid #661ecf;
}
/* Hide auto-generated OpenAPI sections (Authorizations, Headers, Body, Response) */
/* These sections are wrapped in div.api-section with h4 headings */
.prose > .api-section {

View File

@@ -5,9 +5,16 @@ const autumn = new Autumn({
serverURL: "http://localhost:8080",
});
const res = await autumn.check({
customerId: "john",
featureId: "messages",
const res = await autumn.entities.update({
entityId: "seat_1",
billingControls: {
spendLimits: [
{
featureId: "messages",
enabled: true,
},
],
},
});
console.log("Res:", res);

View File

@@ -1,16 +1,16 @@
lockVersion: 2.0.0
id: 05940b80-1ef8-40f4-9878-822fb2792070
management:
docChecksum: 619fa0edb2b0fa4702c58a525589e150
docChecksum: 3dbf423e23e18a6bcdc8b10ec2200e55
docVersion: 2.2.0
speakeasyVersion: 1.759.3
generationVersion: 2.869.25
releaseVersion: 0.4.18
configChecksum: 2263d20254e354a1792248274002f650
persistentEdits:
generation_id: 0154f12e-8c6c-45d4-99e5-198a8741b57a
pristine_commit_hash: 194cf215ae4b326d09d45afa66eb37602cc03c9b
pristine_tree_hash: 5f82579560e167ee78a0c2f4f44c1e72e70838f2
generation_id: 391b32af-db87-4a8f-8847-53fa27235e15
pristine_commit_hash: e6f57708067620df9f78f79b3878a4b0b5dfa23f
pristine_tree_hash: cb4db96e1b466bba00afc7ff7a371ee6cab9f704
features:
python:
additionalDependencies: 1.0.0
@@ -149,8 +149,8 @@ trackedFiles:
pristine_git_object: 4b75b3d142ea7237687dd5d3dd6a0e463186bbcf
docs/models/attachparams.md:
id: cc19aeb7efcc
last_write_checksum: sha1:9ed344bd9f8aad64f2d3da9ac3946f44808f6130
pristine_git_object: d33c0f5f51fd592a1d8b202350ad42088d6e6e9b
last_write_checksum: sha1:e66a03214d5c048013bf153c7850b6135709f57b
pristine_git_object: de3e5a13a80c9da3cc0b1d9de5cd940ce0d1228c
docs/models/attachplanitem.md:
id: 5012fb5dd668
last_write_checksum: sha1:d99041abdc1966381f9c3be442d82ae8894e35d8
@@ -945,8 +945,8 @@ trackedFiles:
pristine_git_object: 3c6a8b29b6df7ff5445d964e3fcb32bd7fbce60e
docs/models/eventsaggregateparams.md:
id: d82804f4b722
last_write_checksum: sha1:f7cfe58b897041b0f49465c26c6d893a1391fcb4
pristine_git_object: aaa002878c0201ad5bde8eac2f5c1143d4f90afb
last_write_checksum: sha1:832ac3f10173c7838f8e0af2a37a85d596c2c89a
pristine_git_object: ff1e1c21d28989589f537f8a32ba9479b2282306
docs/models/eventslistparams.md:
id: ab92b0ecc15c
last_write_checksum: sha1:5e6a8d722d79547d925198b7bab35f071c0fa1fe
@@ -1845,8 +1845,8 @@ trackedFiles:
pristine_git_object: 7ec5834b5a153e688ba7faab51dfd5a65fe868d7
docs/models/previewattachparams.md:
id: 3ba40d589e3d
last_write_checksum: sha1:6529eb223842ae0dd4b80d489fdfe258abb7a595
pristine_git_object: f1b68537849dec7d28dd02718f177b6d295d578a
last_write_checksum: sha1:0c33c78865b8f3e606e0935e8b1e988869dbbe9d
pristine_git_object: 760ef297b673500040a798b82a56c8a15d9cfc55
docs/models/previewattachplanitem.md:
id: db3480cfb03c
last_write_checksum: sha1:f53174ffd7c56ee9e3b7c06a1c17b30ed8fd362e
@@ -2385,8 +2385,8 @@ trackedFiles:
pristine_git_object: 546483389b2445ebcd164757c3fc24f07b48a40a
docs/models/setuppaymentparams.md:
id: d71f82dde273
last_write_checksum: sha1:ea8794fe6f5c47f316256195dcffd9909982659b
pristine_git_object: 254ede9f6fb86edf50a6c1ecba02f8c421a21394
last_write_checksum: sha1:3cbe7a26cdc80e368828bf89030bef0379d1dd0f
pristine_git_object: 767ecc67acc016b3d00c0fc1c21e31380cc95586
docs/models/setuppaymentplanitem.md:
id: b79b706606a1
last_write_checksum: sha1:557beeeacbd10fa2e774d2b9e19f870cd0932d01
@@ -2905,8 +2905,8 @@ trackedFiles:
pristine_git_object: 3bfcffa5d72717d2e6fd6337b6ce28964aaccfac
docs/sdks/billing/README.md:
id: dc915331dd9d
last_write_checksum: sha1:1ebecb2f17f53704d40cc64d2cc24021f192f1c0
pristine_git_object: 3f7a8648ac93ed289da197b41be2772b3281cfd1
last_write_checksum: sha1:fd53999e45a3488ceca9bcf03f28c597c148cb8e
pristine_git_object: 1b3615c14af9c5deadbb1a9ee21fb52e2cd2635f
docs/sdks/customers/README.md:
id: 9332759cffc2
last_write_checksum: sha1:6afa5d58ebf54f5c89b04add33684673976d8a82
@@ -2917,8 +2917,8 @@ trackedFiles:
pristine_git_object: 6f3e85601157d545e5dca304e3aee39a08245f42
docs/sdks/events/README.md:
id: cf45a4390b9b
last_write_checksum: sha1:0a104a07d307d296199ab0b2453eca0ddd5848ff
pristine_git_object: 82d14b22d27b1d99ca66ac4675f9a6a03521d050
last_write_checksum: sha1:4f2cac48c9f7618245e5849e5ef8ebb98c98996d
pristine_git_object: 9a9aa67f9971af460789772b24258e1ee166456a
docs/sdks/features/README.md:
id: e885cfb7247b
last_write_checksum: sha1:ab8f4c6032ebaa6e2636c27c6a0c66138cb00a7d
@@ -2977,8 +2977,8 @@ trackedFiles:
pristine_git_object: b0056b29f4185cce9f09aad5b284a0bf8754266e
src/autumn_sdk/billing.py:
id: e6cffdbf2221
last_write_checksum: sha1:8f146a83f1c427e423ae2e0a43d2b20a6d63a803
pristine_git_object: 0c532d365672a836909daee34a99fe25fe71c0db
last_write_checksum: sha1:a5baa2afa1ce36c85656a788ddda95d5824ccb66
pristine_git_object: a635d0e2756f511ac2f23d2e74f60b60f07ed2c0
src/autumn_sdk/customers.py:
id: 5c5a0a07a433
last_write_checksum: sha1:c845e37685e7d5b126c2c0d085d675b3befd527c
@@ -3009,8 +3009,8 @@ trackedFiles:
pristine_git_object: 1efa993593b78b8f93587adf8a12ce2efbb11002
src/autumn_sdk/events.py:
id: 3421eef7bbd5
last_write_checksum: sha1:bed2d1c349449b99b59f8c34f49e1503dfbef870
pristine_git_object: 1cf1850f9e22472dd7653d4929937c55bbe4e88f
last_write_checksum: sha1:e5fd5118c94af6d3a6929003b09b27397e99851c
pristine_git_object: d0447355ea5a82da0a601da498407913688507d8
src/autumn_sdk/features.py:
id: 79d780190f74
last_write_checksum: sha1:047e1aeb66bc28e3c9441f0b28a14e9512a33c52
@@ -3025,12 +3025,12 @@ trackedFiles:
pristine_git_object: 229b0a10658f9c09cfc54ea730cd0648ddc974f2
src/autumn_sdk/models/aggregateeventsop.py:
id: 01321099f2a5
last_write_checksum: sha1:9bdbc73198b5ca53e5de0d769371a182059234e0
pristine_git_object: 50dcfa36c0c652b9cb6ac6c165c292de2998493e
last_write_checksum: sha1:613cdfaa148d1e35cbcff565cc24eb05052fdea5
pristine_git_object: d772984fdc24748aa65b488a2298edd4fd7a0f16
src/autumn_sdk/models/attachop.py:
id: ebb59e06476c
last_write_checksum: sha1:400be2baf57968dec4da86b47890d48bb1a763c7
pristine_git_object: 434a571efced4fa2a36c4d1cc134d3d573da5e01
last_write_checksum: sha1:ec8ff8deae8502ce95200f5de91c25b45e1313ed
pristine_git_object: e96a12cbbda489245ef458c579f955ebf8e7a92c
src/autumn_sdk/models/balance.py:
id: a6354d7c4b97
last_write_checksum: sha1:dde97ce288519d7caba988d09f419bb0a79719f9
@@ -3121,8 +3121,8 @@ trackedFiles:
pristine_git_object: 857f87628bd168b87758ac04fc88a926255d7451
src/autumn_sdk/models/listcustomersop.py:
id: d7074740b8b0
last_write_checksum: sha1:62ae8f9901eb13e470841f15b064c53907932942
pristine_git_object: 97d04c327704a990cb85e9a52631e9c78322ff87
last_write_checksum: sha1:31e7c98cb4889670876dc6f1e76843cb6b499cce
pristine_git_object: 662d6e4e578253fea5629ff6ce99f4849d925382
src/autumn_sdk/models/listeventsop.py:
id: 751b0200d91d
last_write_checksum: sha1:58643d5dedb4f9f82aa8ddd15ac9f4bb284189cd
@@ -3149,8 +3149,8 @@ trackedFiles:
pristine_git_object: 20844c9b66feb17e013bb74dfc01d2bd845083d3
src/autumn_sdk/models/previewattachop.py:
id: 2b361be4bfa8
last_write_checksum: sha1:af9368c390f7f9ffe05f43c641f5d22d3649f113
pristine_git_object: c0cee69d0ffe9c921108062caf61b0539cd3b614
last_write_checksum: sha1:47f23fde5d8ae8057b12b348121c1c56c147cf99
pristine_git_object: 180cef6d0824c39243388e6f24cf4051f7b687f7
src/autumn_sdk/models/previewmultiattachop.py:
id: 963ffcd646a4
last_write_checksum: sha1:ff0c3a3864e7847d2161202f0ca6f44ed39f1df7
@@ -3169,8 +3169,8 @@ trackedFiles:
pristine_git_object: aa686dd6f85ae1e27450392fcfe02527adfe8e61
src/autumn_sdk/models/setuppaymentop.py:
id: 603339ee67e3
last_write_checksum: sha1:4944e08928a54c16fdafc93ebadd0b5fce2da229
pristine_git_object: 195e95672325f6748f852a838e4f6953c8c2bccf
last_write_checksum: sha1:924ba7fe378ad3ab7fa589250a047e81c14fb394
pristine_git_object: fad035ad63b5423a9e6448f47924cc80bf479a5e
src/autumn_sdk/models/trackop.py:
id: 2a744315e781
last_write_checksum: sha1:e9f1d9c4bbfc47eb5c942f8cfca4a760818bf79d

View File

@@ -56,6 +56,7 @@ class Billing(BaseSDK):
carry_over_usages: Optional[
Union[models.AttachCarryOverUsages, models.AttachCarryOverUsagesTypedDict]
] = None,
metadata: Optional[Dict[str, str]] = None,
retries: OptionalNullable[utils.RetryConfig] = UNSET,
server_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
@@ -84,6 +85,7 @@ class Billing(BaseSDK):
:param processor_subscription_id: The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one.
:param carry_over_balances: Whether to carry over balances from the previous plan.
:param carry_over_usages: Whether to carry over usages from the previous plan.
:param metadata: Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
: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
@@ -133,6 +135,7 @@ class Billing(BaseSDK):
carry_over_usages=utils.get_pydantic_model(
carry_over_usages, Optional[models.AttachCarryOverUsages]
),
metadata=metadata,
)
req = self._build_request(
@@ -241,6 +244,7 @@ class Billing(BaseSDK):
carry_over_usages: Optional[
Union[models.AttachCarryOverUsages, models.AttachCarryOverUsagesTypedDict]
] = None,
metadata: Optional[Dict[str, str]] = None,
retries: OptionalNullable[utils.RetryConfig] = UNSET,
server_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
@@ -269,6 +273,7 @@ class Billing(BaseSDK):
:param processor_subscription_id: The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one.
:param carry_over_balances: Whether to carry over balances from the previous plan.
:param carry_over_usages: Whether to carry over usages from the previous plan.
:param metadata: Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
: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
@@ -318,6 +323,7 @@ class Billing(BaseSDK):
carry_over_usages=utils.get_pydantic_model(
carry_over_usages, Optional[models.AttachCarryOverUsages]
),
metadata=metadata,
)
req = self._build_request_async(
@@ -739,6 +745,7 @@ class Billing(BaseSDK):
models.PreviewAttachCarryOverUsagesTypedDict,
]
] = None,
metadata: Optional[Dict[str, str]] = None,
retries: OptionalNullable[utils.RetryConfig] = UNSET,
server_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
@@ -767,6 +774,7 @@ class Billing(BaseSDK):
:param processor_subscription_id: The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one.
:param carry_over_balances: Whether to carry over balances from the previous plan.
:param carry_over_usages: Whether to carry over usages from the previous plan.
:param metadata: Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
: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
@@ -817,6 +825,7 @@ class Billing(BaseSDK):
carry_over_usages=utils.get_pydantic_model(
carry_over_usages, Optional[models.PreviewAttachCarryOverUsages]
),
metadata=metadata,
)
req = self._build_request(
@@ -932,6 +941,7 @@ class Billing(BaseSDK):
models.PreviewAttachCarryOverUsagesTypedDict,
]
] = None,
metadata: Optional[Dict[str, str]] = None,
retries: OptionalNullable[utils.RetryConfig] = UNSET,
server_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
@@ -960,6 +970,7 @@ class Billing(BaseSDK):
:param processor_subscription_id: The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one.
:param carry_over_balances: Whether to carry over balances from the previous plan.
:param carry_over_usages: Whether to carry over usages from the previous plan.
:param metadata: Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
: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
@@ -1010,6 +1021,7 @@ class Billing(BaseSDK):
carry_over_usages=utils.get_pydantic_model(
carry_over_usages, Optional[models.PreviewAttachCarryOverUsages]
),
metadata=metadata,
)
req = self._build_request_async(
@@ -2242,6 +2254,7 @@ class Billing(BaseSDK):
models.SetupPaymentCarryOverUsagesTypedDict,
]
] = None,
metadata: Optional[Dict[str, str]] = None,
retries: OptionalNullable[utils.RetryConfig] = UNSET,
server_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
@@ -2264,6 +2277,7 @@ class Billing(BaseSDK):
:param processor_subscription_id: The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one.
:param carry_over_balances: Whether to carry over balances from the previous plan.
:param carry_over_usages: Whether to carry over usages from the previous plan.
:param metadata: Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
: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
@@ -2307,6 +2321,7 @@ class Billing(BaseSDK):
carry_over_usages=utils.get_pydantic_model(
carry_over_usages, Optional[models.SetupPaymentCarryOverUsages]
),
metadata=metadata,
)
req = self._build_request(
@@ -2413,6 +2428,7 @@ class Billing(BaseSDK):
models.SetupPaymentCarryOverUsagesTypedDict,
]
] = None,
metadata: Optional[Dict[str, str]] = None,
retries: OptionalNullable[utils.RetryConfig] = UNSET,
server_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
@@ -2435,6 +2451,7 @@ class Billing(BaseSDK):
:param processor_subscription_id: The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one.
:param carry_over_balances: Whether to carry over balances from the previous plan.
:param carry_over_usages: Whether to carry over usages from the previous plan.
:param metadata: Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
: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
@@ -2478,6 +2495,7 @@ class Billing(BaseSDK):
carry_over_usages=utils.get_pydantic_model(
carry_over_usages, Optional[models.SetupPaymentCarryOverUsages]
),
metadata=metadata,
)
req = self._build_request_async(

View File

@@ -249,6 +249,7 @@ class Events(BaseSDK):
]
] = None,
filter_by: Optional[Dict[str, str]] = None,
max_groups: Optional[int] = None,
retries: OptionalNullable[utils.RetryConfig] = UNSET,
server_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
@@ -264,6 +265,7 @@ class Events(BaseSDK):
:param bin_size: Size of the time bins to aggregate events for. Defaults to hour if range is 24h, otherwise day
:param custom_range: Custom time range to aggregate events for. If provided, range must not be provided
:param filter_by: Filter events by property values, e.g. {\"model\": \"gpt-4\", \"region\": \"us\"}. Maximum 5 filters.
:param max_groups: Maximum number of distinct group values to return per time bin when using group_by. Remaining values are bundled into an 'Other' bucket. Defaults to 9
: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
@@ -290,6 +292,7 @@ class Events(BaseSDK):
custom_range, Optional[models.AggregateEventsCustomRange]
),
filter_by=filter_by,
max_groups=max_groups,
)
req = self._build_request(
@@ -369,6 +372,7 @@ class Events(BaseSDK):
]
] = None,
filter_by: Optional[Dict[str, str]] = None,
max_groups: Optional[int] = None,
retries: OptionalNullable[utils.RetryConfig] = UNSET,
server_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
@@ -384,6 +388,7 @@ class Events(BaseSDK):
:param bin_size: Size of the time bins to aggregate events for. Defaults to hour if range is 24h, otherwise day
:param custom_range: Custom time range to aggregate events for. If provided, range must not be provided
:param filter_by: Filter events by property values, e.g. {\"model\": \"gpt-4\", \"region\": \"us\"}. Maximum 5 filters.
:param max_groups: Maximum number of distinct group values to return per time bin when using group_by. Remaining values are bundled into an 'Other' bucket. Defaults to 9
: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
@@ -410,6 +415,7 @@ class Events(BaseSDK):
custom_range, Optional[models.AggregateEventsCustomRange]
),
filter_by=filter_by,
max_groups=max_groups,
)
req = self._build_request_async(

View File

@@ -101,6 +101,8 @@ class EventsAggregateParamsTypedDict(TypedDict):
r"""Custom time range to aggregate events for. If provided, range must not be provided"""
filter_by: NotRequired[Dict[str, str]]
r"""Filter events by property values, e.g. {\"model\": \"gpt-4\", \"region\": \"us\"}. Maximum 5 filters."""
max_groups: NotRequired[int]
r"""Maximum number of distinct group values to return per time bin when using group_by. Remaining values are bundled into an 'Other' bucket. Defaults to 9"""
class EventsAggregateParams(BaseModel):
@@ -127,6 +129,8 @@ class EventsAggregateParams(BaseModel):
filter_by: Optional[Dict[str, str]] = None
r"""Filter events by property values, e.g. {\"model\": \"gpt-4\", \"region\": \"us\"}. Maximum 5 filters."""
max_groups: Optional[int] = None
r"""Maximum number of distinct group values to return per time bin when using group_by. Remaining values are bundled into an 'Other' bucket. Defaults to 9"""
@model_serializer(mode="wrap")
def serialize_model(self, handler):
@@ -139,6 +143,7 @@ class EventsAggregateParams(BaseModel):
"bin_size",
"custom_range",
"filter_by",
"max_groups",
]
)
serialized = handler(self)

View File

@@ -781,6 +781,8 @@ class AttachParamsTypedDict(TypedDict):
r"""Whether to carry over balances from the previous plan."""
carry_over_usages: NotRequired[AttachCarryOverUsagesTypedDict]
r"""Whether to carry over usages from the previous plan."""
metadata: NotRequired[Dict[str, str]]
r"""Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped."""
class AttachParams(BaseModel):
@@ -841,6 +843,9 @@ class AttachParams(BaseModel):
carry_over_usages: Optional[AttachCarryOverUsages] = None
r"""Whether to carry over usages from the previous plan."""
metadata: Optional[Dict[str, str]] = None
r"""Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped."""
@model_serializer(mode="wrap")
def serialize_model(self, handler):
optional_fields = set(
@@ -862,6 +867,7 @@ class AttachParams(BaseModel):
"processor_subscription_id",
"carry_over_balances",
"carry_over_usages",
"metadata",
]
)
serialized = handler(self)

View File

@@ -782,6 +782,8 @@ class PreviewAttachParamsTypedDict(TypedDict):
r"""Whether to carry over balances from the previous plan."""
carry_over_usages: NotRequired[PreviewAttachCarryOverUsagesTypedDict]
r"""Whether to carry over usages from the previous plan."""
metadata: NotRequired[Dict[str, str]]
r"""Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped."""
class PreviewAttachParams(BaseModel):
@@ -842,6 +844,9 @@ class PreviewAttachParams(BaseModel):
carry_over_usages: Optional[PreviewAttachCarryOverUsages] = None
r"""Whether to carry over usages from the previous plan."""
metadata: Optional[Dict[str, str]] = None
r"""Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped."""
@model_serializer(mode="wrap")
def serialize_model(self, handler):
optional_fields = set(
@@ -863,6 +868,7 @@ class PreviewAttachParams(BaseModel):
"processor_subscription_id",
"carry_over_balances",
"carry_over_usages",
"metadata",
]
)
serialized = handler(self)

View File

@@ -717,6 +717,8 @@ class SetupPaymentParamsTypedDict(TypedDict):
r"""Whether to carry over balances from the previous plan."""
carry_over_usages: NotRequired[SetupPaymentCarryOverUsagesTypedDict]
r"""Whether to carry over usages from the previous plan."""
metadata: NotRequired[Dict[str, str]]
r"""Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped."""
class SetupPaymentParams(BaseModel):
@@ -765,6 +767,9 @@ class SetupPaymentParams(BaseModel):
carry_over_usages: Optional[SetupPaymentCarryOverUsages] = None
r"""Whether to carry over usages from the previous plan."""
metadata: Optional[Dict[str, str]] = None
r"""Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped."""
@model_serializer(mode="wrap")
def serialize_model(self, handler):
optional_fields = set(
@@ -783,6 +788,7 @@ class SetupPaymentParams(BaseModel):
"processor_subscription_id",
"carry_over_balances",
"carry_over_usages",
"metadata",
]
)
serialized = handler(self)

View File

@@ -59,6 +59,7 @@ export const eventsAggregateParamsOutboundSchema = z.object({
filter_by: z
.union([z.record(z.string(), z.string()), z.undefined()])
.optional(),
max_groups: z.union([z.number(), z.undefined()]).optional(),
});
const closedEnumSchema = z.any();
@@ -80,4 +81,5 @@ export const eventsAggregateParamsSchema = z.object({
filterBy: z
.union([z.record(z.string(), z.string()), z.undefined()])
.optional(),
maxGroups: z.union([z.number(), z.undefined()]).optional(),
});

View File

@@ -190,6 +190,9 @@ export const attachParamsOutboundSchema = z.object({
carry_over_usages: z
.union([attachCarryOverUsagesOutboundSchema, z.undefined()])
.optional(),
metadata: z
.union([z.record(z.string(), z.string()), z.undefined()])
.optional(),
});
const closedEnumSchema = z.any();
@@ -312,6 +315,9 @@ export const attachParamsSchema = z.object({
carryOverUsages: z
.union([attachCarryOverUsagesSchema, z.undefined()])
.optional(),
metadata: z
.union([z.record(z.string(), z.string()), z.undefined()])
.optional(),
});
export const attachCodeSchema = openEnumSchema;

View File

@@ -281,6 +281,9 @@ export const previewAttachParamsOutboundSchema = z.object({
carry_over_usages: z
.union([previewAttachCarryOverUsagesOutboundSchema, z.undefined()])
.optional(),
metadata: z
.union([z.record(z.string(), z.string()), z.undefined()])
.optional(),
});
const closedEnumSchema = z.any();
@@ -420,6 +423,9 @@ export const previewAttachParamsSchema = z.object({
carryOverUsages: z
.union([previewAttachCarryOverUsagesSchema, z.undefined()])
.optional(),
metadata: z
.union([z.record(z.string(), z.string()), z.undefined()])
.optional(),
});
export const previewAttachIncomingSchema = z.object({

View File

@@ -178,6 +178,9 @@ export const setupPaymentParamsOutboundSchema = z.object({
carry_over_usages: z
.union([setupPaymentCarryOverUsagesOutboundSchema, z.undefined()])
.optional(),
metadata: z
.union([z.record(z.string(), z.string()), z.undefined()])
.optional(),
});
const closedEnumSchema = z.any();
@@ -299,4 +302,7 @@ export const setupPaymentParamsSchema = z.object({
carryOverUsages: z
.union([setupPaymentCarryOverUsagesSchema, z.undefined()])
.optional(),
metadata: z
.union([z.record(z.string(), z.string()), z.undefined()])
.optional(),
});

View File

@@ -6411,6 +6411,15 @@ paths:
required:
- enabled
description: Whether to carry over usages from the previous plan.
metadata:
type: object
propertyNames:
type: string
additionalProperties:
type: string
description: Key-value metadata to attach to the Stripe subscription, invoice,
and checkout session created during this attach flow. Keys
prefixed with 'autumn_' are reserved and will be stripped.
required:
- customer_id
- plan_id
@@ -7411,6 +7420,15 @@ paths:
required:
- enabled
description: Whether to carry over usages from the previous plan.
metadata:
type: object
propertyNames:
type: string
additionalProperties:
type: string
description: Key-value metadata to attach to the Stripe subscription, invoice,
and checkout session created during this attach flow. Keys
prefixed with 'autumn_' are reserved and will be stripped.
required:
- customer_id
- plan_id
@@ -10112,6 +10130,15 @@ paths:
required:
- enabled
description: Whether to carry over usages from the previous plan.
metadata:
type: object
propertyNames:
type: string
additionalProperties:
type: string
description: Key-value metadata to attach to the Stripe subscription, invoice,
and checkout session created during this attach flow. Keys
prefixed with 'autumn_' are reserved and will be stripped.
required:
- customer_id
title: SetupPaymentParams
@@ -11424,6 +11451,13 @@ paths:
type: string
description: 'Filter events by property values, e.g. {"model": "gpt-4",
"region": "us"}. Maximum 5 filters.'
max_groups:
type: integer
minimum: 1
maximum: 250
description: Maximum number of distinct group values to return per time bin when
using group_by. Remaining values are bundled into an 'Other'
bucket. Defaults to 9
required:
- feature_id
title: EventsAggregateParams

View File

@@ -6432,6 +6432,11 @@ paths:
@param carryOverUsages - Whether to carry over usages from the previous
plan. (optional)
@param metadata - Key-value metadata to attach to the Stripe
subscription, invoice, and checkout session created during this attach
flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
(optional)
@returns A billing response with customer ID, invoice details, and
payment URL (if checkout required).
@@ -6828,6 +6833,15 @@ paths:
required:
- enabled
description: Whether to carry over usages from the previous plan.
metadata:
type: object
propertyNames:
type: string
additionalProperties:
type: string
description: Key-value metadata to attach to the Stripe subscription, invoice,
and checkout session created during this attach flow. Keys
prefixed with 'autumn_' are reserved and will be stripped.
required:
- customer_id
- plan_id
@@ -7532,6 +7546,11 @@ paths:
@param carryOverUsages - Whether to carry over usages from the previous
plan. (optional)
@param metadata - Key-value metadata to attach to the Stripe
subscription, invoice, and checkout session created during this attach
flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
(optional)
@returns A preview response with line items, totals, and effective dates
for the proposed changes.
@@ -7928,6 +7947,15 @@ paths:
required:
- enabled
description: Whether to carry over usages from the previous plan.
metadata:
type: object
propertyNames:
type: string
additionalProperties:
type: string
description: Key-value metadata to attach to the Stripe subscription, invoice,
and checkout session created during this attach flow. Keys
prefixed with 'autumn_' are reserved and will be stripped.
required:
- customer_id
- plan_id
@@ -10775,6 +10803,15 @@ paths:
required:
- enabled
description: Whether to carry over usages from the previous plan.
metadata:
type: object
propertyNames:
type: string
additionalProperties:
type: string
description: Key-value metadata to attach to the Stripe subscription, invoice,
and checkout session created during this attach flow. Keys
prefixed with 'autumn_' are reserved and will be stripped.
required:
- customer_id
title: SetupPaymentParams
@@ -12169,6 +12206,13 @@ paths:
type: string
description: 'Filter events by property values, e.g. {"model": "gpt-4",
"region": "us"}. Maximum 5 filters.'
max_groups:
type: integer
minimum: 1
maximum: 250
description: Maximum number of distinct group values to return per time bin when
using group_by. Remaining values are bundled into an 'Other'
bucket. Defaults to 9
required:
- feature_id
title: EventsAggregateParams

View File

@@ -1,16 +1,16 @@
lockVersion: 2.0.0
id: 7b300647-cd76-49e9-bf77-7d1bf5446d66
management:
docChecksum: f98069dc45543e994ea231bd22ec2e47
docChecksum: 394f4462b50f2de78ebf9865156a7582
docVersion: 2.2.0
speakeasyVersion: 1.759.3
generationVersion: 2.869.25
releaseVersion: 0.10.17
configChecksum: c6b2bd1231da8dc3af5be7430f3cfbac
persistentEdits:
generation_id: 7edc2e8f-59c8-46a7-9a5a-e61387a2f36a
pristine_commit_hash: b2e2b8738560f487a83ba1e697d00f5944cce8be
pristine_tree_hash: 82b78ed61f3381875e11aae0bf17c1ff38bb0036
generation_id: a031befa-105b-4b9a-8521-692de3e3e248
pristine_commit_hash: efc89b1c055ba8f54a6601c8e4b6804c79cb0d9e
pristine_tree_hash: 82b45a8dd093043cf3a14be4b5535dad5cccd7d7
features:
typescript:
additionalDependencies: 0.1.0
@@ -172,8 +172,8 @@ trackedFiles:
pristine_git_object: 54f331cb0a0bf32bb0cc3e63eb6652a440b2329e
docs/models/attach-params.md:
id: 83d15924bf0f
last_write_checksum: sha1:959518ac28092d483212c956f82ba82b28b73ca6
pristine_git_object: 1d168244aa39378ad7b214abe8e13f622e73e02d
last_write_checksum: sha1:b3478563c4111f7a85569522c43337f8f72a5890
pristine_git_object: 757370514d5fe11829d850e0470937cc713ac7f1
docs/models/attach-plan-item.md:
id: f8bfccf3f202
last_write_checksum: sha1:b4e51944ee8cdc61dd528fae6b15b4babc4dcaad
@@ -968,8 +968,8 @@ trackedFiles:
pristine_git_object: e934594068751ae479bd067c0e9f4533776642e5
docs/models/events-aggregate-params.md:
id: 081dcd1094d6
last_write_checksum: sha1:e87eaf6ebdbcd9ba1b84cbcf7c06e8f62da8ca3b
pristine_git_object: 77ba2985d82e2f6ba33686e06994536902982037
last_write_checksum: sha1:66a721a5264c50f8f301e28f72ff27f84bba0624
pristine_git_object: fd14516ca6eb58db784ef020daba5546d5f6a4b2
docs/models/events-list-params.md:
id: b743a4817767
last_write_checksum: sha1:5398e590e9563862ff7aea3056fd8f9218f17b6b
@@ -1860,8 +1860,8 @@ trackedFiles:
pristine_git_object: 5d210625047227aeae00e394f0bf104ae18de8d0
docs/models/preview-attach-params.md:
id: 29fbf5be911d
last_write_checksum: sha1:cb5be461a991a5258fc79f140a9c161214232f21
pristine_git_object: 14941682355b27765e55692fcc391a8db1586d61
last_write_checksum: sha1:397e2bdf892cdfd051861c6170c173221276406c
pristine_git_object: a0767307953a89a5f3a4c67ec59632635c2eaf66
docs/models/preview-attach-plan-item.md:
id: fc49e9185703
last_write_checksum: sha1:22ac4a20e6fc8104c0cf8d67add3bdf319ce2162
@@ -2404,8 +2404,8 @@ trackedFiles:
pristine_git_object: 17f67bc80a655ecb35a3af5a043712da68749e8d
docs/models/setup-payment-params.md:
id: 24c3d70301e8
last_write_checksum: sha1:a986f68a47c7b2629465dae5a5d9163b02546fbf
pristine_git_object: 433babe29737dec32eda0e42064158a9f13478c8
last_write_checksum: sha1:615ce2e8766cc8d88f655e524e419585c29a3102
pristine_git_object: 13e36ddf0d146653f17a2e8e8c778b8cf548f13e
docs/models/setup-payment-plan-item.md:
id: 57c94716bfe5
last_write_checksum: sha1:b253abb1fe3bd434dd0e9bcb5c06ff0a536ae5cf
@@ -2920,8 +2920,8 @@ trackedFiles:
pristine_git_object: a366694c3e2f4df91e7ae37aa212d8f07cb83c40
docs/sdks/billing/README.md:
id: dc915331dd9d
last_write_checksum: sha1:e7ceda34964ab16d3b58cdcdad503eac1d549a60
pristine_git_object: d94ecd720f2708a966fb68b01f36fb4cac4a2617
last_write_checksum: sha1:6396d1767afdc701f7de87926178863ec3ab8326
pristine_git_object: 8830fd5c26377212601ca68add4b317593ffa0b2
docs/sdks/customers/README.md:
id: 9332759cffc2
last_write_checksum: sha1:1eb602635426550359b47163b6f402b6c7b959df
@@ -2996,8 +2996,8 @@ trackedFiles:
pristine_git_object: 5cbdc17fad50dd5fe969f2d3b3cb59a166b32049
src/funcs/billing-attach.ts:
id: c23b3cd15f32
last_write_checksum: sha1:fbe2f4f24a4ec6fab29946ed51c88af541ec34a3
pristine_git_object: 81abad9792fe664b0318d0e9aff44f163387939c
last_write_checksum: sha1:ad5b00a868c314b598816121338eeba32c7b5a23
pristine_git_object: fad3eea8e22586e3cb37761cf5a2b7e80b8bf5ce
src/funcs/billing-multi-attach.ts:
id: 67491e2d8249
last_write_checksum: sha1:1f6359df265435a1b930af57f73cd94e52a0d6eb
@@ -3008,8 +3008,8 @@ trackedFiles:
pristine_git_object: fac46490864a5b712d2cc124e69e6cfcaca794f2
src/funcs/billing-preview-attach.ts:
id: d262a9163889
last_write_checksum: sha1:246b4cc033b6ea681b5e8bcf2c787c328b622900
pristine_git_object: 42b2f68fd16c5ee648df9d5172274a0992c17b55
last_write_checksum: sha1:ed39032b103181579a7264ffccac8c2876d6e83e
pristine_git_object: 949882c304af7ed10528cc69195e7330e5ace9d2
src/funcs/billing-preview-multi-attach.ts:
id: 32ecebf960a5
last_write_checksum: sha1:577e614f94744bc31b3e25243daf03d1f7599a6b
@@ -3204,12 +3204,12 @@ trackedFiles:
pristine_git_object: 79e7ce660b3732053e3adcbb5a4cdeb51496e8aa
src/models/aggregate-events-op.ts:
id: 4b7c98b18e2b
last_write_checksum: sha1:8ba7534439a9f226667ce661e5b8fc596f22f592
pristine_git_object: 7bbf87120ea0114fb58217e9c0a246869b0c4605
last_write_checksum: sha1:5aa521962d62951928e3f6c16592ce2c625081c2
pristine_git_object: de7e12d1ca3c8dd221acb912247a67667a914de8
src/models/attach-op.ts:
id: 83ed65c26ab4
last_write_checksum: sha1:e8536a781d7c257fe11f1e1901746919fcd07346
pristine_git_object: 3b9df3953000b671c1327c4bac660847353f4ee8
last_write_checksum: sha1:e84a052dd6fc038af8a396634ad32e956e697890
pristine_git_object: dad6ec6969e4ccee6f4d377ec46c781672bd3282
src/models/autumn-default-error.ts:
id: 2528aa7886eb
last_write_checksum: sha1:4cce18f91be3262ada7d11dcd6326544e2341b58
@@ -3336,8 +3336,8 @@ trackedFiles:
pristine_git_object: 57c2119234cc30673a067a26c8ccca1244b2295f
src/models/preview-attach-op.ts:
id: 3efc6e3443a7
last_write_checksum: sha1:00508a67173212ea0d5b15da08b16432dcbd6bdb
pristine_git_object: 397445fae8ba834df94dc6b64a150d5cf3e2e8d4
last_write_checksum: sha1:fa96f027941154938849d26149ee932c64c5f803
pristine_git_object: f6df2bde98604681ecb2129eeb0bbc148c7135e8
src/models/preview-multi-attach-op.ts:
id: e4847dc281a6
last_write_checksum: sha1:83f3b92521c22c106d11c6d8b42cbba6fcad6cac
@@ -3364,8 +3364,8 @@ trackedFiles:
pristine_git_object: 3774cc1e9bbb80ac592990aa86f8d4a38ee51f29
src/models/setup-payment-op.ts:
id: 0e97e999ff3c
last_write_checksum: sha1:69e6b0bc8d791fc26e40c41399bfc9f1fc83669e
pristine_git_object: c2b62ef5f0339f003f794e29dfdd30792e74d8df
last_write_checksum: sha1:85bfb83e19c8002da3a519122d3b1f2154678f3c
pristine_git_object: 580e0c46388eb7225d9528291a8fe9ba71f217a9
src/models/track-op.ts:
id: 5e6a750e8fec
last_write_checksum: sha1:7ca84225f0debe7dc4f4f4f4586052c07a089b78
@@ -3396,8 +3396,8 @@ trackedFiles:
pristine_git_object: 571de419ea3321d79acec4bddbb46b1580007115
src/sdk/billing.ts:
id: 10905058c4ad
last_write_checksum: sha1:6de4f6f49f746b15fde5f6f0ef89f5442e24487e
pristine_git_object: 7891ff9bce4d17d0fa8675226b696670ab50f39e
last_write_checksum: sha1:bc3bfab08681a278b377ebd8628d8cfebaa464e3
pristine_git_object: 56fd2e85a2761abc8e011c9e057ee33373c8431b
src/sdk/customers.ts:
id: d33e193e0c00
last_write_checksum: sha1:d7bd2dbe3435c37f2abad38c59c64b725a2dac4b

View File

@@ -5967,6 +5967,8 @@ paths:
@param carryOverUsages - Whether to carry over usages from the previous plan. (optional)
@param metadata - Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped. (optional)
@returns A billing response with customer ID, invoice details, and payment URL (if checkout required).
tags:
@@ -6319,6 +6321,13 @@ paths:
required:
- enabled
description: Whether to carry over usages from the previous plan.
metadata:
type: object
propertyNames:
type: string
additionalProperties:
type: string
description: Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
required:
- customer_id
- plan_id
@@ -6953,6 +6962,8 @@ paths:
@param carryOverUsages - Whether to carry over usages from the previous plan. (optional)
@param metadata - Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped. (optional)
@returns A preview response with line items, totals, and effective dates for the proposed changes.
tags:
@@ -7305,6 +7316,13 @@ paths:
required:
- enabled
description: Whether to carry over usages from the previous plan.
metadata:
type: object
propertyNames:
type: string
additionalProperties:
type: string
description: Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
required:
- customer_id
- plan_id
@@ -9909,6 +9927,13 @@ paths:
required:
- enabled
description: Whether to carry over usages from the previous plan.
metadata:
type: object
propertyNames:
type: string
additionalProperties:
type: string
description: Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
required:
- customer_id
title: SetupPaymentParams
@@ -11190,6 +11215,11 @@ paths:
additionalProperties:
type: string
description: 'Filter events by property values, e.g. {"model": "gpt-4", "region": "us"}. Maximum 5 filters.'
max_groups:
type: integer
minimum: 1
maximum: 250
description: Maximum number of distinct group values to return per time bin when using group_by. Remaining values are bundled into an 'Other' bucket. Defaults to 9
required:
- feature_id
title: EventsAggregateParams

View File

@@ -2,15 +2,15 @@ speakeasyVersion: 1.759.3
sources:
Autumn API:
sourceNamespace: autumn-api
sourceRevisionDigest: sha256:c9120d91f3a6d213b18f5dd6e30166e5c0e571397d2589f48a14542e22b729c6
sourceBlobDigest: sha256:e14e20126488cef14fa0812eb36debe42c047cd39110d48bdc56c906d3b203fd
sourceRevisionDigest: sha256:c6c907e08bc2e7113cf94bba3f90a68dce900124556deb927da2934359777b31
sourceBlobDigest: sha256:b42c2947ee91dbaea9ffb930ecf010ca859cb2c71a431457dab06f52fb6d1689
tags:
- latest
- 2.2.0
Autumn API Stripped:
sourceNamespace: autumn-api-stripped
sourceRevisionDigest: sha256:15f9e8c439cfd2e86772d54a70b963903152fd21e6bc8b84084d896a14213983
sourceBlobDigest: sha256:ef4d8c117046b09139b7d2c3094407f043380da43d43dab1747ff95f15ff3920
sourceRevisionDigest: sha256:cce43f514776fb705b16e78d5653d67ed695ec0df5cd1ed9e2c753deb0a4d94a
sourceBlobDigest: sha256:7cfd3f178f4d3575296c349ff5191e0c1130d0ab5dec42ee03a2885ab7faf105
tags:
- latest
- 2.2.0
@@ -18,17 +18,17 @@ targets:
autumn:
source: Autumn API
sourceNamespace: autumn-api
sourceRevisionDigest: sha256:c9120d91f3a6d213b18f5dd6e30166e5c0e571397d2589f48a14542e22b729c6
sourceBlobDigest: sha256:e14e20126488cef14fa0812eb36debe42c047cd39110d48bdc56c906d3b203fd
sourceRevisionDigest: sha256:c6c907e08bc2e7113cf94bba3f90a68dce900124556deb927da2934359777b31
sourceBlobDigest: sha256:b42c2947ee91dbaea9ffb930ecf010ca859cb2c71a431457dab06f52fb6d1689
codeSamplesNamespace: autumn-api-typescript-code-samples
codeSamplesRevisionDigest: sha256:7e87ff21c02896ef86a6ae5817818cc285381d2fda7f8c8a0988b75d81196345
codeSamplesRevisionDigest: sha256:7100ce92530293e3b17e37b4af9532538462db6cdcf017a2dcfd565dae2b76d0
autumn-python:
source: Autumn API Stripped
sourceNamespace: autumn-api-stripped
sourceRevisionDigest: sha256:15f9e8c439cfd2e86772d54a70b963903152fd21e6bc8b84084d896a14213983
sourceBlobDigest: sha256:ef4d8c117046b09139b7d2c3094407f043380da43d43dab1747ff95f15ff3920
sourceRevisionDigest: sha256:cce43f514776fb705b16e78d5653d67ed695ec0df5cd1ed9e2c753deb0a4d94a
sourceBlobDigest: sha256:7cfd3f178f4d3575296c349ff5191e0c1130d0ab5dec42ee03a2885ab7faf105
codeSamplesNamespace: autumn-api-python-code-samples
codeSamplesRevisionDigest: sha256:9bd4d2e74449803b57154411bb5795ed90cca7eddd762a27c3f74e597c433256
codeSamplesRevisionDigest: sha256:7b2433d3b97216ce9d2e89e9bef6d02560f24c60f0a115a3e14ca79f9796422a
workflow:
workflowVersion: 1.0.0
speakeasyVersion: pinned

View File

@@ -257,6 +257,7 @@ const response = await client.billing.attach({ customerId: "cus_123", planId: "p
@param processorSubscriptionId - The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one. (optional)
@param carryOverBalances - Whether to carry over balances from the previous plan. (optional)
@param carryOverUsages - Whether to carry over usages from the previous plan. (optional)
@param metadata - Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped. (optional)
@returns A billing response with customer ID, invoice details, and payment URL (if checkout required).
* [multiAttach](docs/sdks/billing/README.md#multiattach) - Attaches multiple plans to a customer in a single request. Creates a single Stripe subscription with all plans consolidated.
@@ -322,6 +323,7 @@ const response = await client.billing.previewAttach({ customerId: "cus_123", pla
@param processorSubscriptionId - The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one. (optional)
@param carryOverBalances - Whether to carry over balances from the previous plan. (optional)
@param carryOverUsages - Whether to carry over usages from the previous plan. (optional)
@param metadata - Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped. (optional)
@returns A preview response with line items, totals, and effective dates for the proposed changes.
* [previewMultiAttach](docs/sdks/billing/README.md#previewmultiattach) - Previews the billing changes that would occur when attaching multiple plans, without actually making any changes.
@@ -687,6 +689,7 @@ const response = await client.billing.attach({ customerId: "cus_123", planId: "p
@param processorSubscriptionId - The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one. (optional)
@param carryOverBalances - Whether to carry over balances from the previous plan. (optional)
@param carryOverUsages - Whether to carry over usages from the previous plan. (optional)
@param metadata - Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped. (optional)
@returns A billing response with customer ID, invoice details, and payment URL (if checkout required).
- [`billingMultiAttach`](docs/sdks/billing/README.md#multiattach) - Attaches multiple plans to a customer in a single request. Creates a single Stripe subscription with all plans consolidated.
@@ -753,6 +756,7 @@ const response = await client.billing.previewAttach({ customerId: "cus_123", pla
@param processorSubscriptionId - The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one. (optional)
@param carryOverBalances - Whether to carry over balances from the previous plan. (optional)
@param carryOverUsages - Whether to carry over usages from the previous plan. (optional)
@param metadata - Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped. (optional)
@returns A preview response with line items, totals, and effective dates for the proposed changes.
- [`billingPreviewMultiAttach`](docs/sdks/billing/README.md#previewmultiattach) - Previews the billing changes that would occur when attaching multiple plans, without actually making any changes.

View File

@@ -67,6 +67,7 @@ import { Result } from "../types/fp.js";
* @param processorSubscriptionId - The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one. (optional)
* @param carryOverBalances - Whether to carry over balances from the previous plan. (optional)
* @param carryOverUsages - Whether to carry over usages from the previous plan. (optional)
* @param metadata - Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped. (optional)
*
* @returns A billing response with customer ID, invoice details, and payment URL (if checkout required).
*/

View File

@@ -55,6 +55,7 @@ import { Result } from "../types/fp.js";
* @param processorSubscriptionId - The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one. (optional)
* @param carryOverBalances - Whether to carry over balances from the previous plan. (optional)
* @param carryOverUsages - Whether to carry over usages from the previous plan. (optional)
* @param metadata - Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped. (optional)
*
* @returns A preview response with line items, totals, and effective dates for the proposed changes.
*/

View File

@@ -91,6 +91,9 @@ export type EventsAggregateParams = {
* Filter events by property values, e.g. {"model": "gpt-4", "region": "us"}. Maximum 5 filters.
*/
filterBy?: { [k: string]: string } | undefined;
* Maximum number of distinct group values to return per time bin when using group_by. Remaining values are bundled into an 'Other' bucket. Defaults to 9
*/
maxGroups?: number | undefined;
};
export type AggregateEventsList = {
@@ -191,6 +194,7 @@ export type EventsAggregateParams$Outbound = {
bin_size: string;
custom_range?: AggregateEventsCustomRange$Outbound | undefined;
filter_by?: { [k: string]: string } | undefined;
max_groups?: number | undefined;
};
/** @internal */
@@ -209,6 +213,7 @@ export const EventsAggregateParams$outboundSchema: z.ZodMiniType<
z.lazy(() => AggregateEventsCustomRange$outboundSchema),
),
filterBy: z.optional(z.record(z.string(), z.string())),
maxGroups: z.optional(z.int()),
}),
z.transform((v) => {
return remap$(v, {
@@ -219,6 +224,7 @@ export const EventsAggregateParams$outboundSchema: z.ZodMiniType<
binSize: "bin_size",
customRange: "custom_range",
filterBy: "filter_by",
maxGroups: "max_groups",
});
}),
);

View File

@@ -525,6 +525,10 @@ export type AttachParams = {
* Whether to carry over usages from the previous plan.
*/
carryOverUsages?: AttachCarryOverUsages | undefined;
/**
* Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
*/
metadata?: { [k: string]: string } | undefined;
};
/**
@@ -1164,6 +1168,7 @@ export type AttachParams$Outbound = {
processor_subscription_id?: string | undefined;
carry_over_balances?: AttachCarryOverBalances$Outbound | undefined;
carry_over_usages?: AttachCarryOverUsages$Outbound | undefined;
metadata?: { [k: string]: string } | undefined;
};
/** @internal */
@@ -1201,6 +1206,7 @@ export const AttachParams$outboundSchema: z.ZodMiniType<
carryOverUsages: z.optional(
z.lazy(() => AttachCarryOverUsages$outboundSchema),
),
metadata: z.optional(z.record(z.string(), z.string())),
}),
z.transform((v) => {
return remap$(v, {

View File

@@ -544,6 +544,10 @@ export type PreviewAttachParams = {
* Whether to carry over usages from the previous plan.
*/
carryOverUsages?: PreviewAttachCarryOverUsages | undefined;
/**
* Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
*/
metadata?: { [k: string]: string } | undefined;
};
export type PreviewAttachDiscount = {
@@ -1454,6 +1458,7 @@ export type PreviewAttachParams$Outbound = {
processor_subscription_id?: string | undefined;
carry_over_balances?: PreviewAttachCarryOverBalances$Outbound | undefined;
carry_over_usages?: PreviewAttachCarryOverUsages$Outbound | undefined;
metadata?: { [k: string]: string } | undefined;
};
/** @internal */
@@ -1498,6 +1503,7 @@ export const PreviewAttachParams$outboundSchema: z.ZodMiniType<
carryOverUsages: z.optional(
z.lazy(() => PreviewAttachCarryOverUsages$outboundSchema),
),
metadata: z.optional(z.record(z.string(), z.string())),
}),
z.transform((v) => {
return remap$(v, {

View File

@@ -475,6 +475,10 @@ export type SetupPaymentParams = {
* Whether to carry over usages from the previous plan.
*/
carryOverUsages?: SetupPaymentCarryOverUsages | undefined;
/**
* Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.
*/
metadata?: { [k: string]: string } | undefined;
};
/**
@@ -1045,6 +1049,7 @@ export type SetupPaymentParams$Outbound = {
processor_subscription_id?: string | undefined;
carry_over_balances?: SetupPaymentCarryOverBalances$Outbound | undefined;
carry_over_usages?: SetupPaymentCarryOverUsages$Outbound | undefined;
metadata?: { [k: string]: string } | undefined;
};
/** @internal */
@@ -1078,6 +1083,7 @@ export const SetupPaymentParams$outboundSchema: z.ZodMiniType<
carryOverUsages: z.optional(
z.lazy(() => SetupPaymentCarryOverUsages$outboundSchema),
),
metadata: z.optional(z.record(z.string(), z.string())),
}),
z.transform((v) => {
return remap$(v, {

View File

@@ -57,6 +57,7 @@ export class Billing extends ClientSDK {
* @param processorSubscriptionId - The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one. (optional)
* @param carryOverBalances - Whether to carry over balances from the previous plan. (optional)
* @param carryOverUsages - Whether to carry over usages from the previous plan. (optional)
* @param metadata - Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped. (optional)
*
* @returns A billing response with customer ID, invoice details, and payment URL (if checkout required).
*/
@@ -148,6 +149,7 @@ export class Billing extends ClientSDK {
* @param processorSubscriptionId - The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one. (optional)
* @param carryOverBalances - Whether to carry over balances from the previous plan. (optional)
* @param carryOverUsages - Whether to carry over usages from the previous plan. (optional)
* @param metadata - Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped. (optional)
*
* @returns A preview response with line items, totals, and effective dates for the proposed changes.
*/

View File

@@ -1,27 +0,0 @@
#!/bin/bash
# Source shared configuration
source "$(dirname "$0")/config.sh"
# Exit immediately if a command exits with a non-zero status
set -e
# export USE_KERNEL_BROWSER=1
export TEST_FILE_CONCURRENCY=2
BUN_PARALLEL_V2 \
'update-subscription/invoice' \
# 'update-subscription/invoice' \
# 'update-subscription/custom-plan' \
# 'update-subscription/discounts' \
# 'update-subscription/errors' \
# 'update-subscription/free-trial' \
# 'update-subscription/multi-product' \
# 'update-subscription/update-quantity' \
# 'update-subscription/version-update' \
# 'update-subscription/cancel/uncancel' \
# 'update-subscription/cancel/immediately' \
# 'update-subscription/cancel/end-of-cycle' \
# --max=3

View File

@@ -1,4 +1,4 @@
import { AttachScenario, cp } from "@autumn/shared";
import { AttachScenario, cp, type FullCusProduct } from "@autumn/shared";
import { getStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated";
@@ -16,7 +16,7 @@ import { scheduleDefaultProducts } from "./scheduleDefaultProducts";
* 2. Skips if Autumn initiated the cancellation (via lock)
* 3. Marks active customer products as canceled
* 4. Schedules default products for non-add-on groups
* 5. Sends cancel webhooks
* 5. Sends cancel webhooks (after defaults are scheduled)
*/
export const handleStripeSubscriptionCanceled = async ({
ctx,
@@ -25,7 +25,7 @@ export const handleStripeSubscriptionCanceled = async ({
ctx: StripeWebhookContext;
subscriptionUpdatedContext: StripeSubscriptionUpdatedContext;
}): Promise<void> => {
const { db, org, env, logger } = ctx;
const { org, env, logger } = ctx;
const {
stripeSubscription,
previousAttributes,
@@ -56,10 +56,11 @@ export const handleStripeSubscriptionCanceled = async ({
return;
}
// PASS 1: Update cancellation status and send webhooks
const canceledCustomerProducts = [];
// PASS 1: Update cancellation status
const allCanceledProducts: FullCusProduct[] = [];
const canceledNonAddonProducts: FullCusProduct[] = [];
for (const customerProduct of customerProducts) {
// Skip if not active or not on this subscription
const { valid: isActiveRecurringAndOnSub } = cp(customerProduct)
.recurring()
.hasActiveStatus()
@@ -67,7 +68,6 @@ export const handleStripeSubscriptionCanceled = async ({
if (!isActiveRecurringAndOnSub) continue;
// Update cancellation status
const updates = {
canceled_at: canceledAtMs ?? Date.now(),
canceled: true,
@@ -90,6 +90,29 @@ export const handleStripeSubscriptionCanceled = async ({
`[handleStripeSubscriptionCanceled] Marked ${customerProduct.product.name} as canceled`,
);
allCanceledProducts.push(customerProduct);
if (!customerProduct.product.is_add_on) {
canceledNonAddonProducts.push(customerProduct);
}
}
// PASS 2: Schedule default products
let scheduledByGroup = new Map<string, FullCusProduct>();
if (org.config.sync_status && canceledNonAddonProducts.length > 0) {
scheduledByGroup = await scheduleDefaultProducts({
ctx,
subscriptionUpdatedContext,
canceledCustomerProducts: canceledNonAddonProducts,
});
}
// PASS 3: Send cancel webhooks (after defaults are scheduled)
for (const customerProduct of allCanceledProducts) {
const scheduledCusProduct = scheduledByGroup.get(
customerProduct.product.group,
);
await addProductsUpdatedWebhookTask({
ctx,
internalCustomerId: fullCustomer.internal_id,
@@ -98,20 +121,7 @@ export const handleStripeSubscriptionCanceled = async ({
customerId: fullCustomer.id ?? null,
scenario: AttachScenario.Cancel,
cusProduct: customerProduct,
});
// Track for default product scheduling
if (!customerProduct.product.is_add_on) {
canceledCustomerProducts.push(customerProduct);
}
}
// PASS 2: Schedule default products
if (org.config.sync_status && canceledCustomerProducts.length > 0) {
await scheduleDefaultProducts({
ctx,
subscriptionUpdatedContext,
canceledCustomerProducts,
scheduledCusProduct,
});
}
};

View File

@@ -11,6 +11,7 @@ import type { StripeSubscriptionUpdatedContext } from "../../stripeSubscriptionU
/**
* Schedules default products for customer product groups that are being canceled.
* Returns a map of product group -> scheduled customer product.
*/
export const scheduleDefaultProducts = async ({
ctx,
@@ -20,10 +21,12 @@ export const scheduleDefaultProducts = async ({
ctx: StripeWebhookContext;
subscriptionUpdatedContext: StripeSubscriptionUpdatedContext;
canceledCustomerProducts: FullCusProduct[];
}): Promise<void> => {
}): Promise<Map<string, FullCusProduct>> => {
const { db, org, env } = ctx;
const { stripeSubscription, fullCustomer } = subscriptionUpdatedContext;
const scheduledByGroup = new Map<string, FullCusProduct>();
// Fetch default products upfront (optimization)
const defaultProducts = await ProductService.listDefault({
db,
@@ -41,7 +44,7 @@ export const scheduleDefaultProducts = async ({
});
if (!eligibleForDefaultProduct) continue;
await scheduleDefaultProduct({
const scheduledCusProduct = await scheduleDefaultProduct({
ctx,
productGroup: canceledProduct.product.group,
fullCustomer: enrichFullCustomerWithEntity({
@@ -51,5 +54,11 @@ export const scheduleDefaultProducts = async ({
scheduleAtMs,
defaultProducts,
});
if (scheduledCusProduct) {
scheduledByGroup.set(canceledProduct.product.group, scheduledCusProduct);
}
}
return scheduledByGroup;
};

View File

@@ -42,6 +42,7 @@ export const aggregateGroupablePipeParamsSchema = z.object({
filter_value_3: z.string().optional(),
filter_key_4: z.string().optional(),
filter_value_4: z.string().optional(),
max_groups: z.number().int().min(1).max(250).optional(),
});
export type AggregateGroupablePipeParams = z.infer<

View File

@@ -230,7 +230,12 @@ const formatSimpleResults = ({
return { meta, rows: data.length, data };
};
/** Formats groupable pipe results (with grouping) into unpivoted format */
/**
* Formats groupable pipe results using per-bin ranking.
* The Tinybird pipe already ranks per-bin and buckets overflow into AUTUMN_RESERVED.
* This function trusts that ranking — each bin keeps its own top N groups,
* so different bins can show different entities.
*/
const formatGroupableResults = ({
rows,
eventNames,
@@ -247,12 +252,13 @@ const formatGroupableResults = ({
startDate: string;
endDate: string;
binSize: string;
maxGroups?: number;
}): ClickHouseResult => {
const allPeriods = generateAllPeriods({ startDate, endDate, binSize });
// groupBy already comes with "properties." prefix from frontend
const groupByColumn = groupBy;
// Collect all unique group values from the results
// Collect all unique group values across all bins (for backfilling zeros).
// Each bin may have a different set of top-N groups, so the union can exceed N.
const allGroupValues = new Set<string>();
for (const row of rows) {
if (row.group_value) {
@@ -276,7 +282,7 @@ const formatGroupableResults = ({
dataMap.set(period, groupMap);
}
// Fill in actual data
// Fill in actual data directly from the pipe output
for (const row of rows) {
if (!row.group_value) continue;
@@ -291,7 +297,8 @@ const formatGroupableResults = ({
eventName: row.event_name,
noCount,
});
record[columnName] = new Decimal(row.total_value)
record[columnName] = new Decimal(record[columnName] ?? 0)
.plus(new Decimal(row.total_value))
.toDecimalPlaces(10)
.toNumber();
}
@@ -309,13 +316,14 @@ const formatGroupableResults = ({
}
}
// Sort by period then group value (but put "Other" last within each period)
// Sort by period then group value (put AUTUMN_RESERVED last within each period)
data.sort((a, b) => {
const periodCompare = String(a.period).localeCompare(String(b.period));
if (periodCompare !== 0) return periodCompare;
// Put "Other" last
const aIsOther = a[groupByColumn] === "Other";
const bIsOther = b[groupByColumn] === "Other";
const aIsOther =
a[groupByColumn] === "AUTUMN_RESERVED" || a[groupByColumn] === "Other";
const bIsOther =
b[groupByColumn] === "AUTUMN_RESERVED" || b[groupByColumn] === "Other";
if (aIsOther && !bIsOther) return 1;
if (!aIsOther && bIsOther) return -1;
return String(a[groupByColumn]).localeCompare(String(b[groupByColumn]));
@@ -396,6 +404,7 @@ export const aggregate = async ({
group_column: groupColumn,
property_key: propertyKey,
...buildFilterParams({ filter_by: params.filter_by }),
max_groups: params.max_groups,
};
const result = await pipes.aggregateGroupable(pipeParams);
@@ -414,6 +423,7 @@ export const aggregate = async ({
startDate,
endDate,
binSize,
maxGroups: params.max_groups,
});
} else {
// Use aggregate_simple pipe for ungrouped queries

View File

@@ -0,0 +1,65 @@
import { getClickhouseClient } from "@/external/tinybird/initClickhouse.js";
type EntityNameRow = {
id: string;
name: string;
};
/** Escapes a string for safe use in a ClickHouse string literal (single-quoted). */
const escapeChString = ({ value }: { value: string }): string =>
value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
/** Looks up entity names from the entities datasource by their IDs. Returns a map of id -> name (or id if name is null/empty). */
export const getEntityNames = async ({
entityIds,
orgId,
env,
}: {
entityIds: string[];
orgId: string;
env: string;
}): Promise<Record<string, string>> => {
if (entityIds.length === 0) return {};
const ch = getClickhouseClient();
// Build the IN list as escaped literals to avoid URI-too-large
// when the array is serialized as a query parameter.
const inList = entityIds
.map((id) => `'${escapeChString({ value: id })}'`)
.join(",");
const query = `
SELECT id, name
FROM entities FINAL
WHERE org_id = {org_id:String}
AND env = {env:String}
AND id IN (${inList})
AND deleted = 0
`;
const result = await ch.query({
query,
query_params: {
org_id: orgId,
env,
},
format: "JSON",
});
const resultJson = (await result.json()) as { data: EntityNameRow[] };
const nameMap: Record<string, string> = {};
for (const row of resultJson.data) {
nameMap[row.id] = row.name || row.id;
}
// For any IDs not found in the datasource, fall back to the ID itself
for (const id of entityIds) {
if (!nameMap[id]) {
nameMap[id] = id;
}
}
return nameMap;
};

View File

@@ -9,6 +9,7 @@ import { StatusCodes } from "http-status-codes";
import { z } from "zod/v4";
import { assertTinybirdAvailable } from "@/external/tinybird/tinybirdUtils.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { getEntityNames } from "@/internal/analytics/actions/getEntityNames.js";
import { CusService } from "@/internal/customers/CusService.js";
import { eventActions } from "../actions/eventActions.js";
@@ -20,6 +21,7 @@ const InternalAggregateEventsSchema = z.object({
group_by: z.string().optional(),
bin_size: z.enum(["day", "hour", "month"]).optional(),
timezone: z.string().optional(),
max_groups: z.number().int().min(1).max(250).optional(),
});
/**
@@ -31,8 +33,15 @@ export const handleInternalAggregateEvents = createRoute({
assertTinybirdAvailable();
const ctx = c.get("ctx");
const { db, org, env, features } = ctx;
const { interval, customer_id, entity_id, group_by, bin_size, timezone } =
c.req.valid("json");
const {
interval,
customer_id,
entity_id,
group_by,
bin_size,
timezone,
max_groups,
} = c.req.valid("json");
let { event_names } = c.req.valid("json");
let aggregateAll = false;
@@ -87,9 +96,32 @@ export const handleInternalAggregateEvents = createRoute({
group_by: group_by,
customer,
timezone: timezone,
max_groups,
},
});
// When grouping by entity_id, resolve entity names from ClickHouse
let entityNames: Record<string, string> | undefined;
if (group_by === "entity_id" && events?.data) {
const entityIds = [
...new Set(
events.data
.map((row: Record<string, unknown>) => row.entity_id as string)
.filter(
(id: string) => id && id !== "AUTUMN_RESERVED" && id !== "",
),
),
];
if (entityIds.length > 0) {
entityNames = await getEntityNames({
entityIds,
orgId: org.id,
env,
});
}
}
return c.json({
customer,
events,
@@ -97,6 +129,7 @@ export const handleInternalAggregateEvents = createRoute({
eventNames: event_names,
bcExclusionFlag,
truncated,
entityNames,
});
},
});

View File

@@ -24,19 +24,36 @@ export const calculateDeduction = ({
maxBalance,
alterGrantedBalance = false,
}: CalculateDeductionParams): CalculateDeductionResult => {
let newBalance = new Decimal(currentBalance).sub(amountToDeduct).toNumber();
const isRefund = amountToDeduct < 0;
// Apply floor (minBalance)
if (minBalance !== undefined && newBalance < minBalance) {
newBalance = minBalance;
let deducted: number;
let newBalance: number;
if (isRefund) {
const amountToAdd = new Decimal(amountToDeduct).negated().toNumber();
const maxAddable =
maxBalance === undefined
? amountToAdd
: Math.max(
0,
new Decimal(maxBalance).sub(currentBalance).toNumber(),
);
const added = Math.min(amountToAdd, maxAddable);
deducted = -added;
newBalance = new Decimal(currentBalance).add(added).toNumber();
} else {
const maxDeductible =
minBalance === undefined
? amountToDeduct
: Math.max(
0,
new Decimal(currentBalance).sub(minBalance).toNumber(),
);
deducted = Math.min(amountToDeduct, maxDeductible);
newBalance = new Decimal(currentBalance).sub(deducted).toNumber();
}
// Apply ceiling (maxBalance) - for when adding credits
if (maxBalance !== undefined && newBalance > maxBalance) {
newBalance = maxBalance;
}
const deducted = new Decimal(currentBalance).sub(newBalance).toNumber();
const remaining = new Decimal(amountToDeduct).sub(deducted).toNumber();
// Update adjustment if alterGrantedBalance is true

View File

@@ -229,6 +229,7 @@ export const setupAttachBillingContext = async ({
successUrl:
params.success_url ?? orgToReturnUrl({ org: ctx.org, env: ctx.env }),
checkoutSessionParams: params.checkout_session_params,
userMetadata: params.metadata,
externalId: params.subscription_id,

View File

@@ -31,6 +31,8 @@ export const setupAttachProductContext = async ({
orgId: org.id,
env,
version: params.version,
logResult: true,
logger: ctx.logger,
});
// 2. Handle custom items if provided

View File

@@ -7,6 +7,7 @@ import {
findFeatureOptionsByFeature,
InternalError,
isOneOffPrice,
isPrepaidPrice,
type LineItem,
RecaseError,
type UpdateCustomerEntitlement,
@@ -78,7 +79,9 @@ export const computeUpdateQuantityDetails = ({
const customerPrice = findCusPriceByFeature({
internalFeatureId: internalFeatureId,
cusPrices: customerProduct.customer_prices,
cusPrices: customerProduct.customer_prices.filter((cp) =>
isPrepaidPrice(cp.price),
),
errorOnNotFound: true,
});

View File

@@ -40,8 +40,11 @@ export const buildStripeSubscriptionAction = ({
});
const addInvoiceItems = oneOffItemSpecs.map((item) => ({
price: item.stripePriceId,
...(item.stripeInlinePrice
? { price_data: item.stripeInlinePrice }
: { price: item.stripePriceId }),
quantity: item.quantity,
...(item.metadata && { metadata: item.metadata }),
}));
// Case 1: No subscription and sub items update is empty -> no action

View File

@@ -52,6 +52,7 @@ export const executeStripeCheckoutSessionAction = async ({
? { enabled: true }
: undefined,
autumnMetadataId: metadata.id,
userMetadata: billingContext.userMetadata,
});
// 3. Create checkout session with card-type fallback

View File

@@ -10,13 +10,14 @@ import { CusProductService } from "@/internal/customers/cusProducts/CusProductSe
/**
* Maps update phase format to create phase format (strips start_date).
* Preserves discounts so they carry forward to the new schedule phases.
* Preserves inline `price_data` items so standalone schedule creation
* can carry entity-scoped recurring prices forward correctly.
*/
const toCreatePhase = (
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
): Stripe.SubscriptionScheduleCreateParams.Phase => ({
items: phase.items?.map((item) => ({
price: item.price,
...(item.price_data ? { price_data: item.price_data } : { price: item.price }),
quantity: item.quantity,
...(item.metadata && { metadata: item.metadata }),
})),

View File

@@ -1,25 +1,32 @@
import type Stripe from "stripe";
import { mergeStripeMetadata } from "@/internal/billing/v2/providers/stripe/utils/common/mergeStripeMetadata";
export const buildCheckoutSessionMetadata = ({
userMetadata,
paramsMetadata,
checkoutSessionMetadata,
autumnMetadataId,
}: {
userMetadata?: Record<string, string>;
paramsMetadata?: Stripe.MetadataParam;
checkoutSessionMetadata?: Stripe.MetadataParam;
autumnMetadataId?: string;
}) => {
if (!paramsMetadata && !checkoutSessionMetadata && !autumnMetadataId) {
if (
!userMetadata &&
!paramsMetadata &&
!checkoutSessionMetadata &&
!autumnMetadataId
) {
return undefined;
}
return {
...(checkoutSessionMetadata ?? {}),
...(paramsMetadata ?? {}),
...(autumnMetadataId
? {
autumn_metadata_id: autumnMetadataId,
}
: {}),
} satisfies Stripe.MetadataParam;
return mergeStripeMetadata({
userMetadata,
autumnMetadata: {
...(checkoutSessionMetadata ?? {}),
...(paramsMetadata ?? {}),
...(autumnMetadataId ? { autumn_metadata_id: autumnMetadataId } : {}),
},
});
};

View File

@@ -1,4 +1,5 @@
import type Stripe from "stripe";
import { mergeStripeMetadata } from "@/internal/billing/v2/providers/stripe/utils/common/mergeStripeMetadata";
import { buildCheckoutSessionMetadata } from "./buildCheckoutSessionMetadata";
/**
@@ -6,23 +7,27 @@ import { buildCheckoutSessionMetadata } from "./buildCheckoutSessionMetadata";
* are preserved alongside Autumn-internal fields (e.g. trial_end).
*/
const mergeSubscriptionData = ({
userMetadata,
paramsSubscriptionData,
userSubscriptionData,
}: {
userMetadata?: Record<string, string>;
paramsSubscriptionData?: Stripe.Checkout.SessionCreateParams.SubscriptionData;
userSubscriptionData?: Stripe.Checkout.SessionCreateParams.SubscriptionData;
}): Stripe.Checkout.SessionCreateParams.SubscriptionData | undefined => {
if (!paramsSubscriptionData && !userSubscriptionData) {
if (!paramsSubscriptionData && !userSubscriptionData && !userMetadata) {
return undefined;
}
const autumnMetadata = {
...(userSubscriptionData?.metadata ?? {}),
...(paramsSubscriptionData?.metadata ?? {}),
};
return {
...(userSubscriptionData ?? {}),
...(paramsSubscriptionData ?? {}),
metadata: {
...(userSubscriptionData?.metadata ?? {}),
...(paramsSubscriptionData?.metadata ?? {}),
},
metadata: mergeStripeMetadata({ userMetadata, autumnMetadata }) ?? {},
};
};
@@ -34,6 +39,7 @@ export const buildCheckoutSessionParams = ({
defaultInvoiceCreation,
defaultSavedPaymentMethodOptions,
autumnMetadataId,
userMetadata,
}: {
params: Stripe.Checkout.SessionCreateParams;
checkoutSessionParams?: Partial<Stripe.Checkout.SessionCreateParams>;
@@ -42,6 +48,7 @@ export const buildCheckoutSessionParams = ({
defaultInvoiceCreation?: Stripe.Checkout.SessionCreateParams.InvoiceCreation;
defaultSavedPaymentMethodOptions?: Stripe.Checkout.SessionCreateParams.SavedPaymentMethodOptions;
autumnMetadataId?: string;
userMetadata?: Record<string, string>;
}): Stripe.Checkout.SessionCreateParams => {
const mergedParams: Stripe.Checkout.SessionCreateParams = {
...(checkoutSessionParams ?? {}),
@@ -65,11 +72,13 @@ export const buildCheckoutSessionParams = ({
defaultSavedPaymentMethodOptions,
invoice_creation: mergedParams.invoice_creation ?? defaultInvoiceCreation,
metadata: buildCheckoutSessionMetadata({
userMetadata,
paramsMetadata: params.metadata,
checkoutSessionMetadata: checkoutSessionParams?.metadata,
autumnMetadataId,
}),
subscription_data: mergeSubscriptionData({
userMetadata,
paramsSubscriptionData: params.subscription_data as
| Stripe.Checkout.SessionCreateParams.SubscriptionData
| undefined,

View File

@@ -4,6 +4,7 @@ import {
filterCustomerProductsByActiveStatuses,
isPrepaidPrice,
priceUtils,
type StripeItemSpec,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
@@ -13,6 +14,77 @@ import { filterStripeItemSpecsByLargestInterval } from "@/internal/billing/v2/pr
import { stripeItemSpecToCheckoutLineItem } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam";
import { updateOneOffTieredItems } from "./updateOneOffTieredItems";
const isZeroAmountInlineLineItem = ({
lineItem,
}: {
lineItem: Stripe.Checkout.SessionCreateParams.LineItem;
}) => {
if (!("price_data" in lineItem) || !lineItem.price_data) return false;
return (
lineItem.price_data.unit_amount === 0 ||
lineItem.price_data.unit_amount_decimal === "0"
);
};
const isZeroAmountInlineRecurringStripeItemSpec = ({
stripeItemSpec,
}: {
stripeItemSpec: StripeItemSpec;
}) => {
if (!stripeItemSpec.stripeInlinePrice?.recurring) return false;
return stripeItemSpec.stripeInlinePrice.unit_amount_decimal === "0";
};
const getRecurringCadenceKey = ({
stripeItemSpec,
}: {
stripeItemSpec: StripeItemSpec;
}) => {
const recurring = stripeItemSpec.stripeInlinePrice?.recurring;
if (recurring) {
return JSON.stringify({
interval: recurring.interval,
intervalCount: recurring.interval_count ?? 1,
});
}
const price = stripeItemSpec.autumnPrice;
if (!price) return "unknown";
return JSON.stringify({
interval: price.config.interval,
intervalCount: price.config.interval_count ?? 1,
});
};
const filterRecurringStripeItemSpecsForCheckout = ({
stripeItemSpecs,
}: {
stripeItemSpecs: StripeItemSpec[];
}) => {
return stripeItemSpecs.filter((stripeItemSpec, index) => {
if (!isZeroAmountInlineRecurringStripeItemSpec({ stripeItemSpec })) {
return true;
}
const recurringCadenceKey = getRecurringCadenceKey({ stripeItemSpec });
const hasNonZeroSiblingWithSameRecurring = stripeItemSpecs.some(
(otherStripeItemSpec, otherIndex) =>
otherIndex !== index &&
getRecurringCadenceKey({ stripeItemSpec: otherStripeItemSpec }) ===
recurringCadenceKey &&
!isZeroAmountInlineRecurringStripeItemSpec({
stripeItemSpec: otherStripeItemSpec,
}),
);
return !hasNonZeroSiblingWithSameRecurring;
});
};
export const buildStripeCheckoutSessionItems = ({
ctx,
billingContext,
@@ -48,6 +120,9 @@ export const buildStripeCheckoutSessionItems = ({
recurringStripeItemSpecs = filterStripeItemSpecsByLargestInterval({
stripeItemSpecs: recurringStripeItemSpecs,
});
recurringStripeItemSpecs = filterRecurringStripeItemSpecsForCheckout({
stripeItemSpecs: recurringStripeItemSpecs,
});
// 5. Convert recurring item specs to line items
const recurringLineItems = recurringStripeItemSpecs.map((item) => {
@@ -83,7 +158,7 @@ export const buildStripeCheckoutSessionItems = ({
const oneOffLineItems = updateOneOffTieredItems({
oneOffItemSpecs,
org: ctx.org,
});
}).filter((lineItem) => !isZeroAmountInlineLineItem({ lineItem }));
return { recurringLineItems, oneOffLineItems };
};

View File

@@ -9,6 +9,7 @@ import {
usagePriceToLineItem,
} from "@autumn/shared";
import type Stripe from "stripe";
import { stripeItemSpecToCheckoutLineItem } from "../stripeItemSpec/stripeItemSpecToStripeParam";
/**
* Update one-off items to use inline price_data if they're tiered.
@@ -33,10 +34,7 @@ export const updateOneOffTieredItems = ({
!autumnProduct ||
!priceIsTieredOneOff({ price: autumnPrice, product: autumnProduct })
) {
return {
price: item.stripePriceId,
quantity: item.quantity ?? 1,
};
return stripeItemSpecToCheckoutLineItem({ spec: item });
}
if (!autumnCusEnt) {

View File

@@ -0,0 +1,28 @@
import type Stripe from "stripe";
const AUTUMN_METADATA_PREFIX = "autumn_";
/** Merges user-provided metadata with Autumn's internal metadata, filtering reserved `autumn_*` keys. */
export const mergeStripeMetadata = ({
userMetadata,
autumnMetadata,
}: {
userMetadata?: Record<string, string>;
autumnMetadata?: Stripe.MetadataParam;
}): Stripe.MetadataParam | undefined => {
if (!userMetadata && !autumnMetadata) return undefined;
const safeUserMetadata: Stripe.MetadataParam = {};
if (userMetadata) {
for (const [key, value] of Object.entries(userMetadata)) {
if (!key.startsWith(AUTUMN_METADATA_PREFIX)) {
safeUserMetadata[key] = value;
}
}
}
return {
...safeUserMetadata,
...(autumnMetadata ?? {}),
};
};

View File

@@ -15,6 +15,7 @@ import {
import type { Stripe } from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { mergeStripeMetadata } from "@/internal/billing/v2/providers/stripe/utils/common/mergeStripeMetadata";
const stripeDiscountsToInvoiceParams = ({
stripeDiscounts,
@@ -68,10 +69,13 @@ export const createInvoiceForBilling = async ({
? "send_invoice"
: "charge_automatically";
const invoiceMetadata: Stripe.InvoiceCreateParams["metadata"] = {
autumn_billing_update: "true",
autumn_invoice_mode: billingContext.invoiceMode ? "true" : "false",
};
const invoiceMetadata = mergeStripeMetadata({
userMetadata: billingContext.userMetadata,
autumnMetadata: {
autumn_billing_update: "true",
autumn_invoice_mode: billingContext.invoiceMode ? "true" : "false",
},
});
const invoiceEligibleStripeDiscounts = getInvoiceEligibleStripeDiscounts({
stripeDiscounts: billingContext.stripeDiscounts ?? [],

View File

@@ -2,6 +2,7 @@ import type { BillingContext, StripeSubscriptionAction } from "@autumn/shared";
import { InternalError, nullish } from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { mergeStripeMetadata } from "@/internal/billing/v2/providers/stripe/utils/common/mergeStripeMetadata";
import { willStripeSubscriptionUpdateCreateInvoice } from "./willStripeSubscriptionUpdateCreateInvoice";
export const executeStripeSubscriptionOperation = async ({
@@ -46,6 +47,10 @@ export const executeStripeSubscriptionOperation = async ({
? { default_payment_method: paymentMethod.id }
: {};
const userMeta = mergeStripeMetadata({
userMetadata: billingContext.userMetadata,
});
switch (subscriptionAction.type) {
case "update": {
let stripeSubscription = billingContext.stripeSubscription;
@@ -71,6 +76,7 @@ export const executeStripeSubscriptionOperation = async ({
...subscriptionAction.params,
...(subscriptionHasDefaultPm ? {} : fallbackPaymentMethodParams),
...(updateWillCreateInvoice ? invoiceModeParams : {}),
...(userMeta && { metadata: userMeta }),
payment_behavior: "error_if_incomplete",
expand: ["latest_invoice"],
},
@@ -81,6 +87,7 @@ export const executeStripeSubscriptionOperation = async ({
...subscriptionAction.params,
...invoiceModeParams,
...fallbackPaymentMethodParams,
...(userMeta && { metadata: userMeta }),
billing_mode: { type: "flexible" },

View File

@@ -32,6 +32,7 @@ export const handleExternalAggregateEvents = createRoute({
bin_size,
custom_range,
filter_by,
max_groups,
} = c.req.valid("json");
console.log("handleAggregateEvents", {
@@ -88,6 +89,7 @@ export const handleExternalAggregateEvents = createRoute({
custom_range,
enforceGroupLimit: true,
filter_by,
max_groups,
},
}),
eventActions.getCountAndSum({

View File

@@ -26,6 +26,7 @@ import {
sql,
} from "drizzle-orm";
import { StatusCodes } from "http-status-codes";
import type { Logger } from "@/external/logtail/logtailUtils";
import { queryWithCache } from "@/utils/cacheUtils/queryWithCache";
import { buildProductsCacheKey, PRODUCTS_CACHE_TTL } from "./productCacheUtils";
import { getLatestProducts, isFreeProduct } from "./productUtils";
@@ -372,6 +373,8 @@ export class ProductService {
env,
version,
allowNotFound = false,
logResult = false,
logger,
}: {
db: DrizzleCli;
idOrInternalId: string;
@@ -379,6 +382,8 @@ export class ProductService {
env: AppEnv;
version?: number;
allowNotFound?: boolean;
logResult?: boolean;
logger?: Logger;
}) {
const data = (await db.query.products.findFirst({
where: and(
@@ -405,6 +410,20 @@ export class ProductService {
parseFreeTrials({ product: data });
if (logResult && logger) {
logger.info("full product:", {
data: {
result: data,
params: {
idOrInternalId,
orgId,
env,
version,
},
},
});
}
if (!data) {
if (allowNotFound) return null as unknown as FullProduct;
throw new ProductNotFoundError({ productId: idOrInternalId, version });

View File

@@ -0,0 +1,256 @@
/**
* Attach Metadata Tests
*
* Tests for the first-class `metadata` field on attach params.
* Verifies that user-provided metadata is correctly passed through to
* Stripe subscriptions, invoices, and checkout sessions, while ensuring
* Autumn's reserved `autumn_*` keys are never overridden.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3, AttachParamsV1Input } from "@autumn/shared";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { timeout } from "@tests/utils/genUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { CusService } from "@/internal/customers/CusService";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: metadata passthrough on subscription (non-checkout flow)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("metadata: passthrough to subscription")}`, async () => {
const customerId = "attach-metadata-sub-invoice";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro-metadata-passthrough",
items: [messagesItem],
});
const { autumnV1, autumnV2_1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await autumnV2_1.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
metadata: {
user_id: "u-123",
campaign: "summer",
},
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer,
productId: pro.id,
});
// Verify Stripe subscription has user-provided metadata
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const stripeCustomerId = fullCustomer.processor?.id;
expect(stripeCustomerId).toBeDefined();
const subs = await ctx.stripeCli.subscriptions.list({
customer: stripeCustomerId!,
status: "all",
});
const subscription = subs.data.find(
(sub) => sub.status === "active" || sub.status === "trialing",
);
expect(subscription).toBeDefined();
expect(subscription!.metadata.user_id).toBe("u-123");
expect(subscription!.metadata.campaign).toBe("summer");
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: autumn_* prefixed keys are stripped from user metadata
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("metadata: autumn_* keys are stripped")}`, async () => {
const customerId = "attach-metadata-strip-autumn";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro-metadata-strip",
items: [messagesItem],
});
const { autumnV1, autumnV2_1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await autumnV2_1.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
metadata: {
user_id: "u-456",
autumn_evil: "hacked",
autumn_billing_update: "overridden",
},
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer,
productId: pro.id,
});
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const stripeCustomerId = fullCustomer.processor?.id;
expect(stripeCustomerId).toBeDefined();
const subs = await ctx.stripeCli.subscriptions.list({
customer: stripeCustomerId!,
status: "all",
});
const subscription = subs.data.find(
(sub) => sub.status === "active" || sub.status === "trialing",
);
expect(subscription).toBeDefined();
// User's safe key should be present
expect(subscription!.metadata.user_id).toBe("u-456");
// Autumn-prefixed keys should NOT be on the subscription
expect(subscription!.metadata.autumn_evil).toBeUndefined();
expect(subscription!.metadata.autumn_billing_update).toBeUndefined();
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: metadata passthrough on proration invoice (immediate upgrade)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("metadata: passthrough to proration invoice on upgrade")}`, async () => {
const customerId = "attach-metadata-upgrade-invoice";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free-metadata-inv",
items: [messagesItem],
});
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro-metadata-inv",
items: [proMessagesItem],
});
const { autumnV2_1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const result = await autumnV2_1.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
metadata: {
user_id: "u-invoice-test",
campaign: "upgrade-promo",
},
});
expect(result.invoice).toBeDefined();
expect(result.invoice!.stripe_id).toBeDefined();
const stripeInvoice = await ctx.stripeCli.invoices.retrieve(
result.invoice!.stripe_id,
);
expect(stripeInvoice.metadata?.user_id).toBe("u-invoice-test");
expect(stripeInvoice.metadata?.campaign).toBe("upgrade-promo");
expect(stripeInvoice.metadata?.autumn_billing_update).toBe("true");
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: metadata passthrough via Stripe checkout flow
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("metadata: passthrough via Stripe checkout")}`, async () => {
const customerId = "attach-metadata-checkout";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro-metadata-checkout",
items: [messagesItem],
});
const { autumnV1, autumnV2_1, ctx } = await initScenario({
customerId,
setup: [s.customer({ testClock: true }), s.products({ list: [pro] })],
actions: [],
});
const result = await autumnV2_1.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
metadata: {
source: "web",
campaign_id: "camp-789",
},
});
expect(result.payment_url).toBeDefined();
expect(result.payment_url).toContain("checkout.stripe.com");
await completeStripeCheckoutForm({ url: result.payment_url });
await timeout(12000);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer,
productId: pro.id,
});
// Verify Stripe subscription has user-provided metadata
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const stripeCustomerId = fullCustomer.processor?.id;
expect(stripeCustomerId).toBeDefined();
const subs = await ctx.stripeCli.subscriptions.list({
customer: stripeCustomerId!,
status: "all",
});
const subscription = subs.data.find(
(sub) => sub.status === "active" || sub.status === "trialing",
);
expect(subscription).toBeDefined();
expect(subscription!.metadata.source).toBe("web");
expect(subscription!.metadata.campaign_id).toBe("camp-789");
});

View File

@@ -1,19 +1,54 @@
import { expect, test } from "bun:test";
import type {
ApiCustomerV3,
ApiCustomerV5,
ApiEntityV0,
CustomerBillingControls,
} from "@autumn/shared";
import { BillingMethod } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProductCorrect } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
import { expectCustomerProductOptions } from "@tests/integration/utils/expectCustomerProductOptions";
import { TestFeature } from "@tests/setup/v2Features";
import { completeStripeCheckoutFormV2 } from "@tests/utils/browserPool";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { timeout } from "@tests/utils/genUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { Decimal } from "decimal.js";
const BILLING_UNITS = 100;
const PRICE_PER_UNIT = 10;
const INCLUDED_USAGE = 100;
const AUTO_TOPUP_WAIT_MS = 20000;
const VOLUME_TIERS = [
{ to: 500, amount: 0, flat_amount: 0 },
{ to: "inf" as const, amount: 0, flat_amount: 50 },
];
const makeAutoTopupConfig = ({
threshold = 20,
quantity = 100,
enabled = true,
}: {
threshold?: number;
quantity?: number;
enabled?: boolean;
} = {}): CustomerBillingControls => ({
auto_topups: [
{
feature_id: TestFeature.Messages,
enabled,
threshold,
quantity,
},
],
});
test.concurrent(`${chalk.yellowBright("attach: stripe checkout prepaid entities")}`, async () => {
const customerId = "prepaid-ent-two-included";
@@ -90,6 +125,356 @@ test.concurrent(`${chalk.yellowBright("attach: stripe checkout prepaid entities"
customerId,
});
});
test.concurrent(`${chalk.yellowBright("attach: stripe checkout monthly price with zero prepaid one-off")}`, async () => {
const customerId = "stripe-checkout-monthly-oneoff-zero";
const monthlyBasePrice = 20;
const includedUsage = 100;
const autoTopupQuantity = 100;
const autoTopupThreshold = 20;
const trackedUsage = 85;
const monthlyPriceItem = items.monthlyPrice({ price: monthlyBasePrice });
const monthlyMessagesItem = items.monthlyMessages({ includedUsage });
const prepaidOneOffItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
price: PRICE_PER_UNIT,
});
const product = products.base({
id: "base-monthly-oneoff-zero",
items: [monthlyPriceItem, monthlyMessagesItem, prepaidOneOffItem],
});
const { autumnV1, autumnV2_1, ctx } = await initScenario({
customerId,
setup: [s.customer({ testClock: true }), s.products({ list: [product] })],
actions: [],
});
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: 0 }],
redirect_mode: "if_required",
});
expect(result.payment_url).toBeDefined();
expect(result.payment_url).toContain("checkout.stripe.com");
await completeStripeCheckoutFormV2({ url: result.payment_url });
await timeout(12000);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProductCorrect({
customerId,
customer,
productId: product.id,
state: "active",
});
expectCustomerFeatureCorrect({
customerId,
customer,
featureId: TestFeature.Messages,
includedUsage,
balance: includedUsage,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customerId,
customer,
count: 1,
latestTotal: monthlyBasePrice,
latestStatus: "paid",
});
await expectStripeSubscriptionCorrect({
ctx,
customerId,
});
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
threshold: autoTopupThreshold,
quantity: autoTopupQuantity,
}),
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: trackedUsage,
});
await timeout(AUTO_TOPUP_WAIT_MS);
const customerAfterTopup =
await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
const expectedRemaining = new Decimal(includedUsage)
.sub(trackedUsage)
.add(autoTopupQuantity)
.toNumber();
expectBalanceCorrect({
customer: customerAfterTopup,
featureId: TestFeature.Messages,
remaining: expectedRemaining,
usage: trackedUsage,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: PRICE_PER_UNIT,
latestStatus: "paid",
});
await expectCustomerProductOptions({
ctx,
customerId,
productId: product.id,
featureId: TestFeature.Messages,
quantity: 1,
});
});
test.concurrent(`${chalk.yellowBright("attach: stripe checkout monthly volume prepaid + consumable update from zero")}`, async () => {
const customerId = "stripe-checkout-monthly-volume-zero-update";
const monthlyBasePrice = 20;
const consumableIncludedUsage = 100;
const checkoutPrepaidQuantity = 600;
const expectedPrepaidCharge = 50;
const monthlyPriceItem = items.monthlyPrice({ price: monthlyBasePrice });
const consumableMessagesItem = items.consumableMessages({
includedUsage: consumableIncludedUsage,
});
const prepaidVolumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: 1,
tiers: VOLUME_TIERS,
});
const product = products.base({
id: "base-monthly-volume-zero-update",
items: [monthlyPriceItem, consumableMessagesItem, prepaidVolumeItem],
});
const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true }),
s.products({ list: [product] }),
s.entities({ count: 1, featureId: TestFeature.Users }),
],
actions: [],
});
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: product.id,
entity_id: entities[0].id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 0,
},
],
redirect_mode: "if_required",
});
expect(result.payment_url).toBeDefined();
expect(result.payment_url).toContain("checkout.stripe.com");
await completeStripeCheckoutFormV2({
url: result.payment_url,
});
await timeout(12000);
const entity = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[0].id,
);
await expectCustomerProductCorrect({
customerId,
customer: entity,
productId: product.id,
state: "active",
});
expectCustomerFeatureCorrect({
customerId,
customer: entity,
featureId: TestFeature.Messages,
includedUsage: consumableIncludedUsage,
balance: consumableIncludedUsage,
usage: 0,
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const entityAfter = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[0].id,
);
await expectCustomerInvoiceCorrect({
customerId,
customer,
count: 1,
latestTotal: monthlyBasePrice,
latestStatus: "paid",
});
await expectStripeSubscriptionCorrect({
ctx,
customerId,
});
await autumnV1.subscriptions.update({
customer_id: customerId,
entity_id: entityAfter.id,
product_id: product.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: checkoutPrepaidQuantity,
},
],
recalculate_balances: {
enabled: true,
},
});
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const customerAfterV2_2 =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectCustomerFeatureCorrect({
customerId,
customer: customerAfter,
featureId: TestFeature.Messages,
includedUsage: checkoutPrepaidQuantity + consumableIncludedUsage,
balance: checkoutPrepaidQuantity + consumableIncludedUsage,
usage: 0,
});
expectBalanceCorrect({
customer: customerAfterV2_2,
featureId: TestFeature.Messages,
remaining: checkoutPrepaidQuantity + consumableIncludedUsage,
usage: 0,
breakdown: {
[BillingMethod.UsageBased]: {
included_grant: consumableIncludedUsage,
remaining: consumableIncludedUsage,
usage: 0,
},
[BillingMethod.Prepaid]: {
prepaid_grant: checkoutPrepaidQuantity,
remaining: checkoutPrepaidQuantity,
usage: 0,
},
},
});
await expectCustomerInvoiceCorrect({
customerId,
customer: customerAfter,
count: 2,
latestTotal: expectedPrepaidCharge,
latestStatus: "paid",
});
await expectStripeSubscriptionCorrect({
ctx,
customerId,
});
});
test.concurrent(`${chalk.yellowBright("attach: stripe checkout annual price with monthly volume prepaid + consumable update from zero")}`, async () => {
const customerId = "stripe-checkout-annual-volume-zero-update";
const annualBasePrice = 200;
const consumableIncludedUsage = 100;
const checkoutPrepaidQuantity = 600;
const annualPriceItem = items.annualPrice({ price: annualBasePrice });
const consumableMessagesItem = items.consumableMessages({
includedUsage: consumableIncludedUsage,
});
const prepaidVolumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: 1,
tiers: VOLUME_TIERS,
});
const product = products.base({
id: "base-annual-volume-zero-update",
items: [annualPriceItem, consumableMessagesItem, prepaidVolumeItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [s.customer({ testClock: true }), s.products({ list: [product] })],
actions: [],
});
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: product.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 0,
adjustable: true,
},
],
redirect_mode: "if_required",
});
expect(result.payment_url).toBeDefined();
expect(result.payment_url).toContain("checkout.stripe.com");
await completeStripeCheckoutFormV2({
url: result.payment_url,
});
await timeout(12000);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProductCorrect({
customerId,
customer,
productId: product.id,
state: "active",
});
expectCustomerFeatureCorrect({
customerId,
customer,
featureId: TestFeature.Messages,
includedUsage: consumableIncludedUsage,
balance: consumableIncludedUsage,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customerId,
customer,
count: 1,
latestTotal: annualBasePrice,
latestStatus: "paid",
});
await expectStripeSubscriptionCorrect({
ctx,
customerId,
});
});
test.concurrent(`${chalk.yellowBright("attach: stripe checkout prepaid volume entities")}`, async () => {
const customerId = "prepaid-ent-two-volume";
const quantity1 = 600;

View File

@@ -258,12 +258,82 @@ test.concurrent(`${chalk.yellowBright("recurring-oneoff 2: attach with only one-
});
});
test.concurrent(`${chalk.yellowBright("recurring-oneoff 3: attach one off base price with consumable messages")}`, async () => {
const customerId = "recurring-oneoff-attach-both-invoice-mode-false";
test.concurrent(`${chalk.yellowBright("recurring-oneoff 3: attach with tiered one-off inline price")}`, async () => {
const customerId = "recurring-oneoff-tiered-inline";
const basePrice = 20;
const wordsPricePerPack = 15;
const messagesPricePerPack = 10;
const quantity = 800;
const billingUnits = 100;
const includedUsage = 100;
const expectedOneOffTotal = 5 * 10 + 2 * 5;
const expectedTotal = basePrice + expectedOneOffTotal;
const tieredOneOffMessagesItem = items.tieredOneOffMessages({
includedUsage,
billingUnits,
tiers: [
{ to: 500, amount: 10 },
{ to: "inf", amount: 5 },
],
});
const product = products.pro({
id: "pro-recurring-tiered-oneoff",
items: [tieredOneOffMessagesItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [product] }),
],
actions: [],
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(preview.total).toBe(expectedTotal);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer,
productId: product.id,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: expectedTotal,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
test.concurrent(`${chalk.yellowBright("recurring-oneoff 4: attach one off base price with consumable messages")}`, async () => {
const customerId = "recurring-oneoff-attach-both-invoice-mode-false";
const messagesPricePerPack = 10;
const consumableMessagesItem = items.consumableMessages({
includedUsage: 0,

View File

@@ -0,0 +1,112 @@
import { test } from "bun:test";
import type { ApiEntityV0 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import {
expectCustomerProducts,
expectProductCanceling,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
const INCLUDED_USAGE = 100;
const VOLUME_TIERS = [
{ to: 500, amount: 0, flat_amount: 0 },
{ to: "inf" as const, amount: 0, flat_amount: 50 },
];
test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-edge 1: volume prepaid downgrade keeps inline schedule items")}`, async () => {
const customerId = "sched-prepaid-ent-volume-inline";
const premiumQuantity = 600;
const proQuantity = 300;
const volumePrepaidItem = items.volumePrepaidMessages({
includedUsage: INCLUDED_USAGE,
billingUnits: 1,
tiers: VOLUME_TIERS,
});
const premium = products.premium({
id: "premium-volume-prepaid",
items: [volumePrepaidItem],
});
const pro = products.pro({
id: "pro-volume-prepaid",
items: [volumePrepaidItem],
});
const { autumnV1, entities, ctx, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
s.entities({ count: 1, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({
productId: premium.id,
entityIndex: 0,
options: [
{ feature_id: TestFeature.Messages, quantity: premiumQuantity },
],
}),
],
});
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
entity_id: entities[0].id,
options: [{ feature_id: TestFeature.Messages, quantity: proQuantity }],
redirect_mode: "if_required",
});
const entityBeforeCycle = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[0].id,
);
await expectProductCanceling({
customer: entityBeforeCycle,
productId: premium.id,
});
await expectProductScheduled({
customer: entityBeforeCycle,
productId: pro.id,
});
expectCustomerFeatureCorrect({
customer: entityBeforeCycle,
featureId: TestFeature.Messages,
balance: premiumQuantity,
usage: 0,
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
});
const entityAfterCycle = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[0].id,
);
await expectCustomerProducts({
customer: entityAfterCycle,
active: [pro.id],
notPresent: [premium.id],
});
expectCustomerFeatureCorrect({
customer: entityAfterCycle,
featureId: TestFeature.Messages,
balance: proQuantity,
usage: 0,
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});

View File

@@ -16,7 +16,9 @@ import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/e
import {
expectProductActive,
expectProductCanceling,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { getSubscriptionId } from "@tests/integration/billing/utils/stripe/getSubscriptionId";
import {
getTestSvixAppId,
setupWebhookTest,
@@ -29,6 +31,7 @@ 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";
import { timeout } from "@/utils/genUtils";
type CustomerProductsUpdatedPayload = {
type: string;
@@ -224,6 +227,92 @@ test.concurrent(`${chalk.yellowBright("webhook: cancel end of cycle (with free d
expect(data.entity).toBeUndefined();
});
// ═══════════════════════════════════════════════════════════════════════════════
// WEBHOOK TESTS - STRIPE-INITIATED CANCEL (default scheduled before webhook)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("webhook: Stripe-initiated cancel fires after default product scheduled")}`, async () => {
const customerId = "webhook-stripe-cancel-default";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ id: "pro", items: [messagesItem] });
const freeDefault = products.base({
id: "free-default",
items: [messagesItem],
isDefault: true,
});
const { autumnV1, ctx: testCtx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success", skipWebhooks: true }),
s.products({ list: [pro, freeDefault] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Verify pro is active
const customerAfterAttach =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterAttach,
productId: pro.id,
});
// Get Stripe subscription ID and cancel externally via Stripe CLI
const subscriptionId = await getSubscriptionId({
ctx: testCtx,
customerId,
productId: pro.id,
});
await testCtx.stripeCli.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
// Wait for webhook triggered by the Stripe-initiated cancellation
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.scenario === "cancel",
timeoutMs: 20000,
});
expect(result).not.toBeNull();
expect(result?.payload.type).toBe("customer.products.updated");
const { data } = result!.payload;
expect(data.scenario).toBe("cancel");
expect(data.updated_product).toBeDefined();
expect(data.updated_product.id).toBe(pro.id);
expect(data.customer).toBeDefined();
expect(data.customer.id).toBe(customerId);
// The webhook customer should include the scheduled default product
// because webhooks now fire AFTER defaults are scheduled
const scheduledProduct = data.customer.products.find(
(p) => p.id === freeDefault.id,
);
expect(scheduledProduct).toBeDefined();
expect(scheduledProduct!.status).toBe("scheduled");
// Also verify via API that the state is correct
await timeout(2000);
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerAfterCancel,
productId: pro.id,
});
await expectProductScheduled({
customer: customerAfterCancel,
productId: freeDefault.id,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// WEBHOOK TESTS - UNCANCEL
// ═══════════════════════════════════════════════════════════════════════════════

View File

@@ -191,7 +191,7 @@ test.concurrent(`${chalk.yellowBright("trial-update: free to allocated users whi
// No charge during trial
expectCustomerInvoiceCorrect({
customer,
count: 1, // Just the $0 trial invoice
count: 2, // Just the $0 trial invoice
latestTotal: 0,
});

View File

@@ -6,6 +6,7 @@ import {
ResetInterval,
type UpdateSubscriptionV1ParamsInput,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
import { TestFeature } from "@tests/setup/v2Features.js";
@@ -207,6 +208,111 @@ test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage: increase
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage: increasing zero prepaid preserves usage-based grant")}`, async () => {
const customerId = "qty-prepaid-preserve-usage-grant";
const monthlyBasePrice = 20;
const consumableIncludedUsage = 100;
const checkoutPrepaidQuantity = 600;
const expectedPrepaidCharge = 50;
const product = products.base({
id: "prepaid-preserve-usage-grant",
items: [
items.monthlyPrice({ price: monthlyBasePrice }),
items.consumableMessages({
includedUsage: consumableIncludedUsage,
}),
items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: 1,
tiers: [
{ to: 500, amount: 0, flat_amount: 0 },
{ to: "inf", amount: 0, flat_amount: 50 },
],
}),
],
});
const { autumnV2_1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [product] }),
],
actions: [
s.billing.attach({
productId: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: 0 }],
}),
],
});
const customerBefore =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
remaining: consumableIncludedUsage,
usage: 0,
breakdown: {
[BillingMethod.UsageBased]: {
included_grant: consumableIncludedUsage,
remaining: consumableIncludedUsage,
usage: 0,
},
[BillingMethod.Prepaid]: {
prepaid_grant: 0,
remaining: 0,
usage: 0,
},
},
});
await autumnV2_1.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
customer_id: customerId,
plan_id: product.id,
feature_quantities: [
{
feature_id: TestFeature.Messages,
quantity: checkoutPrepaidQuantity,
},
],
recalculate_balances: {
enabled: true,
},
});
const customerAfter =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: customerAfter,
featureId: TestFeature.Messages,
remaining: checkoutPrepaidQuantity + consumableIncludedUsage,
usage: 0,
breakdown: {
[BillingMethod.UsageBased]: {
included_grant: consumableIncludedUsage,
remaining: consumableIncludedUsage,
usage: 0,
},
[BillingMethod.Prepaid]: {
prepaid_grant: checkoutPrepaidQuantity,
remaining: checkoutPrepaidQuantity,
usage: 0,
},
},
});
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: expectedPrepaidCharge,
latestStatus: "paid",
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage: decrease quantity with balance recalculation")}`, async () => {
const customerId = "qty-prepaid-overage-decrease";
const product = buildPrepaidOverageProduct({

View File

@@ -0,0 +1,298 @@
import { describe, expect, test } from "bun:test";
import {
BillWhen,
BillingInterval,
type EntityBalance,
type FullCusEntWithFullCusProduct,
PriceType,
} from "@autumn/shared";
import { deductFromCusEntsTypescript } from "@/internal/balances/track/deductUtils/deductFromCusEntsTypescript";
const createCustomerEntitlement = ({
id,
balance,
adjustment = 0,
quantity = 0,
usageAllowed = false,
entities,
}: {
id: string;
balance: number;
adjustment?: number;
quantity?: number;
usageAllowed?: boolean;
entities?: Record<string, EntityBalance>;
}): FullCusEntWithFullCusProduct => {
const entitlementId = `ent-${id}`;
const customerProductId = `cus-prod-${id}`;
return {
id: `cus-ent-${id}`,
internal_customer_id: "internal-customer",
internal_entity_id: null,
internal_feature_id: "internal-feature-messages",
customer_id: "customer-1",
feature_id: "messages",
entitlement_id: entitlementId,
customer_product_id: customerProductId,
created_at: 1,
unlimited: false,
balance,
additional_balance: 0,
adjustment,
entities: entities ?? null,
usage_allowed: usageAllowed,
next_reset_at: null,
expires_at: null,
cache_version: 0,
external_id: null,
replaceables: [],
rollovers: [],
entitlement: {
id: entitlementId,
internal_feature_id: "internal-feature-messages",
internal_product_id: "internal-product-1",
is_custom: false,
allowance_type: "fixed",
allowance: 0,
interval: BillingInterval.Month,
interval_count: 1,
carry_from_previous: false,
entity_feature_id: null,
org_id: "org-1",
feature_id: "messages",
usage_limit: null,
rollover: null,
feature: {
id: "messages",
internal_id: "internal-feature-messages",
name: "Messages",
type: "metered",
config: {},
org_id: "org-1",
env: "sandbox",
created_at: 1,
deleted_at: null,
},
},
customer_product: {
id: customerProductId,
internal_id: customerProductId,
internal_customer_id: "internal-customer",
internal_product_id: "internal-product-1",
internal_entity_id: null,
customer_id: "customer-1",
product_id: `product-${id}`,
name: `Product ${id}`,
group: "",
created_at: 1,
ended_at: null,
canceled_at: null,
cancel_at: null,
expires_at: null,
trial_ends_at: null,
trial_started_at: null,
anchor_at: null,
quantity: 1,
status: "active",
canceled: false,
version: 1,
entity_id: null,
replaces_customer_product_id: null,
options: [
{
feature_id: "messages",
internal_feature_id: "internal-feature-messages",
quantity,
},
],
product: {
internal_id: "internal-product-1",
id: `product-${id}`,
name: `Product ${id}`,
description: null,
org_id: "org-1",
created_at: 1,
env: "sandbox",
is_add_on: false,
is_default: false,
group: "",
version: 1,
processor: {},
base_variant_id: null,
archived: false,
free_trials: [],
free_trial: null,
prices: [],
entitlements: [],
},
customer_entitlements: [],
customer_prices: [
{
id: `cus-price-${id}`,
price_id: `price-${id}`,
customer_product_id: customerProductId,
created_at: 1,
price: {
id: `price-${id}`,
org_id: "org-1",
internal_product_id: "internal-product-1",
config: {
type: PriceType.Usage,
bill_when: BillWhen.InAdvance,
billing_units: 100,
internal_feature_id: "internal-feature-messages",
feature_id: "messages",
usage_tiers: [{ to: "inf", amount: 10 }],
interval: BillingInterval.Month,
interval_count: 1,
stripe_meter_id: null,
stripe_price_id: null,
stripe_empty_price_id: null,
stripe_product_id: null,
stripe_placeholder_price_id: null,
stripe_event_name: null,
stripe_prepaid_price_v2_id: null,
should_prorate: false,
},
created_at: 1,
billing_type: null,
tier_behavior: null,
is_custom: false,
entitlement_id: entitlementId,
proration_config: {},
},
},
],
},
} as unknown as FullCusEntWithFullCusProduct;
};
describe("deductFromCusEntsTypescript", () => {
test("refund: positive usage bucket stays full while prepaid bucket grows", () => {
const prepaidCustomerEntitlement = createCustomerEntitlement({
id: "prepaid",
balance: 0,
quantity: 6,
usageAllowed: false,
});
const usageCustomerEntitlement = createCustomerEntitlement({
id: "usage",
balance: 100,
quantity: 0,
usageAllowed: true,
});
const { updates, remaining } = deductFromCusEntsTypescript({
cusEnts: [prepaidCustomerEntitlement, usageCustomerEntitlement],
amountToDeduct: -600,
allowOverage: true,
});
expect(remaining).toBe(0);
expect(updates[prepaidCustomerEntitlement.id]?.balance).toBe(600);
expect(updates[usageCustomerEntitlement.id]?.balance).toBe(100);
});
test("refund pass 1: only negative balances are healed toward zero", () => {
const negativeCustomerEntitlement = createCustomerEntitlement({
id: "negative",
balance: -40,
usageAllowed: true,
});
const positiveCustomerEntitlement = createCustomerEntitlement({
id: "positive",
balance: 100,
usageAllowed: true,
});
const { updates, remaining } = deductFromCusEntsTypescript({
cusEnts: [negativeCustomerEntitlement, positiveCustomerEntitlement],
amountToDeduct: -30,
allowOverage: false,
});
expect(remaining).toBe(0);
expect(updates[negativeCustomerEntitlement.id]?.balance).toBe(-10);
expect(updates[positiveCustomerEntitlement.id]).toBeUndefined();
});
test("refund pass 2: caps at starting balance when overage is not allowed", () => {
const cappedCustomerEntitlement = createCustomerEntitlement({
id: "capped",
balance: 0,
quantity: 3,
usageAllowed: false,
});
const { updates, remaining } = deductFromCusEntsTypescript({
cusEnts: [cappedCustomerEntitlement],
amountToDeduct: -500,
allowOverage: false,
});
expect(updates[cappedCustomerEntitlement.id]?.balance).toBe(300);
expect(remaining).toBe(-200);
});
test("deduction: prepaid drains before usage-based overage", () => {
const prepaidCustomerEntitlement = createCustomerEntitlement({
id: "prepaid",
balance: 300,
quantity: 3,
usageAllowed: false,
});
const usageCustomerEntitlement = createCustomerEntitlement({
id: "usage",
balance: 0,
usageAllowed: true,
});
const { updates, remaining } = deductFromCusEntsTypescript({
cusEnts: [prepaidCustomerEntitlement, usageCustomerEntitlement],
amountToDeduct: 450,
allowOverage: true,
});
expect(remaining).toBe(0);
expect(updates[prepaidCustomerEntitlement.id]?.balance).toBe(0);
expect(updates[usageCustomerEntitlement.id]?.balance).toBe(-150);
});
test("entity target: only the targeted entity balance is refunded", () => {
const entityCustomerEntitlement = createCustomerEntitlement({
id: "entity",
balance: 0,
usageAllowed: false,
entities: {
"entity-1": {
id: "entity-1",
balance: 0,
adjustment: 0,
additional_balance: 0,
},
"entity-2": {
id: "entity-2",
balance: 100,
adjustment: 0,
additional_balance: 0,
},
},
});
const { updates, remaining } = deductFromCusEntsTypescript({
cusEnts: [entityCustomerEntitlement],
amountToDeduct: -50,
targetEntityId: "entity-1",
allowOverage: true,
});
expect(remaining).toBe(0);
expect(updates[entityCustomerEntitlement.id]?.entities?.["entity-1"]?.balance).toBe(
50,
);
expect(updates[entityCustomerEntitlement.id]?.entities?.["entity-2"]?.balance).toBe(
100,
);
});
});

View File

@@ -1,18 +1,22 @@
DESCRIPTION >
Aggregate queries with grouping by a property key, customer_id, or entity_id.
Uses PER-BIN TOP 9 groups + "AUTUMN_RESERVED" bucket (10 max total per bin).
Each time bin shows its own top 9 groups, not global top 9.
Uses PER-BIN TOP N groups + "AUTUMN_RESERVED" bucket ((N+1) max total per bin).
N is controlled by the max_groups parameter (default 9).
Each time bin shows its own top N groups, not global top N.
Returns unpivoted data: (period, event_name, group_value, total_value, _truncated)
_truncated is true only if at least one bin has more than 9 unique values.
_truncated is true only if at least one bin has more than max_groups unique values.
Frontend maps "AUTUMN_RESERVED" to "Other values" for display.
Parameters:
- group_column: 'customer_id' to group by customer, 'entity_id' to group by entity, 'property' (default) to group by property_key
- max_groups: maximum number of distinct groups to show per bin (default 9, remainder bucketed into AUTUMN_RESERVED)
TOKEN "aggregate_groupable_read" READ
NODE base
DESCRIPTION >
Aggregate raw data by period, event_name, and group_value.
Excludes rows with empty/null group values to prevent irrelevant events
from polluting the top-N and "Other" buckets.
SQL >
%
SELECT
@@ -60,6 +64,12 @@ SQL >
{% if defined(filter_key_4) and String(filter_key_4, '') != '' %}
AND {{ column('properties.' + String(filter_key_4, '')) }}::String = {{ String(filter_value_4, '') }}
{% end %}
{% if String(group_column, 'property') == 'entity_id' %}
AND entity_id IS NOT NULL AND entity_id != ''
{% elif String(group_column, 'property') == 'property' %}
AND {{ column('properties.' + String(property_key, '')) }}::String IS NOT NULL
AND {{ column('properties.' + String(property_key, '')) }}::String != ''
{% end %}
GROUP BY period, event_name, group_value
NODE ranked
@@ -74,12 +84,13 @@ SQL >
NODE endpoint
TYPE endpoint
SQL >
%
SELECT
period,
event_name,
if(rn <= 9, group_value, 'AUTUMN_RESERVED') as group_value,
if(rn <= {{ Int32(max_groups, 9) }}, group_value, 'AUTUMN_RESERVED') as group_value,
sum(total_value) as total_value,
max(rn) > 9 as _truncated
max(rn) > {{ Int32(max_groups, 9) }} as _truncated
FROM ranked
GROUP BY period, event_name, group_value
ORDER BY period, event_name, group_value

View File

@@ -69,6 +69,11 @@ export const AttachParamsV1Schema = BillingParamsBaseV1Schema.extend({
description: "Whether to carry over usages from the previous plan.",
}),
metadata: z.record(z.string(), z.string()).optional().meta({
description:
"Key-value metadata to attach to the Stripe subscription, invoice, and checkout session created during this attach flow. Keys prefixed with 'autumn_' are reserved and will be stripped.",
}),
no_billing_changes: z.boolean().optional().meta({
internal: true,
}),

View File

@@ -58,6 +58,9 @@ export const ExtEventsAggregateParamsSchema = z.object({
filter_by: z.record(z.string(), z.string()).optional().meta({
description:
'Filter events by property values, e.g. {"model": "gpt-4", "region": "us"}. Maximum 5 filters.',
max_groups: z.number().int().min(1).max(250).optional().meta({
description:
"Maximum number of distinct group values to return per time bin when using group_by. Remaining values are bundled into an 'Other' bucket. Defaults to 9",
}),
});

View File

@@ -70,6 +70,7 @@ export interface BillingContext {
billingVersion: BillingVersion;
successUrl?: string;
checkoutSessionParams?: Record<string, unknown>;
userMetadata?: Record<string, string>;
skipBillingChanges?: boolean;

View File

@@ -34,6 +34,7 @@ export type TimeseriesEventsParams = TotalEventsParams & {
no_count?: boolean;
timezone?: string;
enforceGroupLimit?: boolean;
max_groups?: number;
};
export type CalculateDateRangeParams = Omit<

View File

@@ -2,6 +2,7 @@ import { InternalError } from "@api/errors";
import { ms } from "@utils/common";
import {
isPayPerUsePrice,
isPrepaidPrice,
isVolumePrice,
} from "@utils/productUtils/priceUtils/classifyPriceUtils";
import type {
@@ -123,3 +124,21 @@ export const isUsageBasedAllocatedCustomerEntitlement = (
return isAllocated && isUsageBased;
};
/** Whether the customer entitlement has a prepaid price (usage billed in advance). */
export const isPrepaidCustomerEntitlement = (
cusEnt: FullCusEntWithFullCusProduct,
) => {
const cusPrice = cusEntToCusPrice({ cusEnt });
if (!cusPrice) return false;
return isPrepaidPrice(cusPrice.price);
};
/** Whether the customer entitlement has a pay-per-use price (usage billed in arrears). */
export const isPayPerUseCustomerEntitlement = (
cusEnt: FullCusEntWithFullCusProduct,
) => {
const cusPrice = cusEntToCusPrice({ cusEnt });
if (!cusPrice) return false;
return isPayPerUsePrice({ price: cusPrice.price });
};

View File

@@ -3,7 +3,7 @@ import { ArrowRightIcon } from "@phosphor-icons/react";
import { AutumnProvider } from "autumn-js/react";
import { NuqsAdapter } from "nuqs/adapters/react-router/v7";
import { useEffect, useRef, useState } from "react";
import { Outlet, useNavigate } from "react-router";
import { Navigate, Outlet, useNavigate } from "react-router";
import { CustomToaster } from "@/components/general/CustomToaster";
import { SandboxBanner } from "@/components/general/SandboxBanner";
import { IconButton } from "@/components/v2/buttons/IconButton";
@@ -50,6 +50,13 @@ export function MainLayout() {
return () => window.removeEventListener("error", handleGlobalError);
}, [handleApiError]);
// Redirect to sign in if no session
useEffect(() => {
if (!isPending && !data) {
navigate("/sign-in");
}
}, [isPending, data, navigate]);
// Redirect to sandbox if not deployed
useEffect(() => {
if (!orgLoading && org && !org.deployed && env !== AppEnv.Sandbox) {
@@ -84,10 +91,8 @@ export function MainLayout() {
);
}
// 2. If no user, redirect to sign in
if (!data) {
navigate("/sign-in");
return;
return <Navigate to="/sign-in" replace={true} />;
}
return (

View File

@@ -1,6 +1,5 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Table } from "@/components/ui/table";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useScrollbarWidth } from "@/hooks/useScrollbarWidth";
import { cn } from "@/lib/utils";
import { TableColumnVisibility } from "./table-column-visibility";
@@ -24,7 +23,6 @@ export function TableContentVirtualized({
virtualization,
} = context;
const { isLoading } = context;
const sheetType = useSheetStore((s) => s.type);
const rows = table.getRowModel().rows;
// Use state instead of ref so changes trigger re-renders for virtualizer
@@ -89,18 +87,13 @@ export function TableContentVirtualized({
<TableContext.Provider value={contextWithRef}>
<div
className={cn(
"rounded-lg shadow-[0_0_8px_rgba(0,0,0,0.04)] border relative z-50 min-w-0 overflow-hidden",
"rounded-lg border relative z-50 min-w-0 overflow-hidden",
!rows.length &&
"border-dashed bg-interactive-secondary dark:bg-transparent",
className,
)}
>
{/* Overlay - shown when a sheet is open or data is loading */}
{(sheetType || isLoading) && (
<div className="bg-white/60 dark:bg-black/60 absolute pointer-events-none rounded-lg -inset-[1px] z-70" />
)}
{/* Fixed header table - scrolls horizontally in sync with body */}
{/* Fixed header table - scrolls horizontally in sync with body */}
<div
ref={headerRef}
className="overflow-x-auto overflow-y-hidden scrollbar-none"
@@ -138,7 +131,7 @@ export function TableContentVirtualized({
key={visibleColumnKey}
ref={setScrollContainer}
onScroll={handleScroll}
className="rounded-b-lg w-full overflow-auto"
className="w-full overflow-auto"
style={{
minHeight,
maxHeight: virtualization?.containerHeight

View File

@@ -1,5 +1,4 @@
import { Table } from "@/components/ui/table";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { cn } from "@/lib/utils";
import { TableColumnVisibility } from "./table-column-visibility";
import { useTableContext } from "./table-context";
@@ -13,28 +12,22 @@ export function TableContent({
}) {
const { flexibleTableColumns, enableColumnVisibility, table } =
useTableContext();
const sheetType = useSheetStore((s) => s.type);
const rows = table.getRowModel().rows;
return (
<div
className={cn(
"rounded-lg shadow-[0_0_8px_rgba(0,0,0,0.04)] border relative z-50 min-w-0",
"rounded-lg shadow-card border relative z-50 min-w-0",
!rows.length &&
"border-dashed bg-interactive-secondary dark:bg-transparent",
"border-dashed shadow-none",
className,
)}
>
{" "}
{enableColumnVisibility && (
<div className="absolute right-2 top-1 z-45 h-fit">
<TableColumnVisibility />
</div>
)}
{/* OVERLAY */}
{sheetType && (
<div className="bg-white/60 dark:bg-black/60 absolute pointer-events-none rounded-lg -inset-[1px] z-70 "></div>
)}
{enableColumnVisibility && (
<div className="absolute right-2 top-1 z-45 h-fit">
<TableColumnVisibility />
</div>
)}
<Table
className="p-0 w-full rounded-lg overflow-auto"
flexibleTableColumns={flexibleTableColumns}

View File

@@ -4,23 +4,12 @@ import { useEffect } from "react";
import { authClient, useListOrganizations } from "@/lib/auth-client";
import { useAxiosInstance } from "@/services/useAxiosInstance";
const ORG_STORAGE_KEY = "autumn_org";
let lastSwitchedOrgId: string | null = null;
export const setLastSwitchedOrgId = (id: string) => {
lastSwitchedOrgId = id;
};
export const getLastSwitchedOrgId = () => lastSwitchedOrgId;
/** Clears all org-related localStorage cache entries. Call before reload on session changes (impersonation start/stop). */
export const clearOrgCache = () => {
for (const key of Object.keys(localStorage)) {
if (key.startsWith(ORG_STORAGE_KEY)) {
localStorage.removeItem(key);
}
}
};
export const useOrg = (params?: { env?: AppEnv }) => {
const axiosInstance = useAxiosInstance({ env: params?.env });
const { data: orgList } = useListOrganizations();
@@ -28,32 +17,12 @@ export const useOrg = (params?: { env?: AppEnv }) => {
const fetcher = async () => {
try {
const { data } = await axiosInstance.get("/organization");
if (data) {
const storageKey = params?.env
? `${ORG_STORAGE_KEY}_${params.env}`
: ORG_STORAGE_KEY;
localStorage.setItem(storageKey, JSON.stringify(data));
}
return data;
} catch {
return null;
}
};
const getInitialData = () => {
try {
const storageKey = params?.env
? `${ORG_STORAGE_KEY}_${params.env}`
: ORG_STORAGE_KEY;
const stored = localStorage.getItem(storageKey);
return stored ? JSON.parse(stored) : undefined;
} catch {
return undefined;
}
};
const initialDataValue = getInitialData();
const {
data: org,
isLoading,
@@ -62,8 +31,9 @@ export const useOrg = (params?: { env?: AppEnv }) => {
} = useQuery({
queryKey: params?.env ? ["org", params.env] : ["org"],
queryFn: fetcher,
initialData: initialDataValue,
placeholderData: keepPreviousData,
refetchOnWindowFocus: true,
staleTime: 30_000,
});
useEffect(() => {
@@ -76,10 +46,10 @@ export const useOrg = (params?: { env?: AppEnv }) => {
} else {
console.log("No org to set active, signing out");
await authClient.signOut();
window.location.href = "/sign-in";
}
};
// 1. If no org...
if (!org && !isLoading) {
handleNoActiveOrg();
}

View File

@@ -132,7 +132,7 @@ html {
--chart-9: #349eff;
--chart-10: #27a7ff;
--chart-grid-stroke: #d1d1d1;
--chart-grid-stroke: #d1d1d190;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
@@ -318,6 +318,7 @@ html {
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg:
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-card: 0 0 8px rgba(0, 0, 0, 0.04);
--color-yellow-50: #fefce8;
--color-yellow-100: #fdedd3;

View File

@@ -2,7 +2,6 @@ import { Globe } from "@phosphor-icons/react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { clearOrgCache } from "@/hooks/common/useOrg";
import { authClient } from "@/lib/auth-client";
import { AdminOrgTable } from "@/views/admin/AdminOrgTable";
import { AdminUserTable } from "@/views/admin/AdminUserTable";
@@ -35,7 +34,6 @@ export const AdminView = () => {
return;
}
clearOrgCache();
window.location.reload();
};

View File

@@ -2,7 +2,6 @@ import { AlertCircle, Loader2, ShieldCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { Button } from "@/components/v2/buttons/Button";
import { clearOrgCache } from "@/hooks/common/useOrg";
import { authClient } from "@/lib/auth-client";
import { useAxiosInstance } from "../../services/useAxiosInstance";
import { useAdmin } from "./hooks/useAdmin";
@@ -65,9 +64,6 @@ export function ImpersonateRedirect() {
// Step 5: Navigate to the redirect path
setStatus("Redirecting...");
// Clear stale org cache before navigating so the new session loads fresh data
clearOrgCache();
window.location.href = redirect;
} catch (err: unknown) {
const errorMessage =

View File

@@ -10,7 +10,6 @@ import type {
Rollover,
} from "@autumn/shared";
import { toast } from "sonner";
import { clearOrgCache } from "@/hooks/common/useOrg";
import { authClient } from "@/lib/auth-client";
import { formatUnixToDate } from "../../utils/formatUtils/formatDateUtils";
@@ -66,7 +65,6 @@ export const impersonateUser = async ({
await authClient.organization.setActive({ organizationId });
}
clearOrgCache();
window.location.reload();
};

View File

@@ -1,5 +1,4 @@
import { type GroupedPermission, groupAndFormatScopes } from "@autumn/shared";
import { clearOrgCache } from "@/hooks/common/useOrg";
import {
Check,
ChevronDown,
@@ -147,7 +146,6 @@ export const Consent = () => {
await authClient.organization.setActive({
organizationId: orgId,
});
clearOrgCache();
window.location.reload();
} catch (_) {
toast.error("Failed to switch organization");

View File

@@ -4,17 +4,13 @@ import { Mail } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner";
import { z } from "zod/v4";
import { CustomToaster } from "@/components/general/CustomToaster";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { Input } from "@/components/v2/inputs/Input";
import { useOrg } from "@/hooks/common/useOrg";
import { authClient, signIn } from "@/lib/auth-client";
import { authClient, signIn, useSession } from "@/lib/auth-client";
import { getBackendErr } from "@/utils/genUtils";
import { OTPSignIn } from "./components/OTPSignIn";
const emailSchema = z.string().email();
/**
* Check if URL has OAuth parameters (from OAuth provider redirect)
* These params are added by better-auth when redirecting unauthenticated users
@@ -40,7 +36,7 @@ export const SignIn = () => {
const [sendOtpLoading, setSendOtpLoading] = useState(false);
const [otpSent, setOtpSent] = useState(false);
const { org } = useOrg();
const { data: session } = useSession();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -66,14 +62,10 @@ export const SignIn = () => {
}
// Regular sign-in flow - redirect to dashboard if already authenticated
if (org) {
if (org.deployed) {
navigate("/products?tab=products");
} else {
navigate("/sandbox/products?tab=products");
}
if (session) {
navigate("/", { replace: true });
}
}, [org, navigate, oauthRedirectUrl]);
}, [session, navigate, oauthRedirectUrl]);
const handleEmailSignIn = async (e: React.FormEvent) => {
e.preventDefault();

View File

@@ -9,22 +9,18 @@ import LoadingScreen from "../general/LoadingScreen";
import { OnboardingGuide } from "../onboarding4/OnboardingGuide";
import { CustomersContext } from "./CustomersContext";
import { useCusSearchQuery } from "./hooks/useCusSearchQuery";
import { useCustomerFilters } from "./hooks/useCustomerFilters";
import { useFullCusSearchQuery } from "./hooks/useFullCusSearchQuery";
import {
restoreCustomerFilters,
usePersistedFilters,
} from "./hooks/usePersistedFilters";
import { useSavedViewsQuery } from "./hooks/useSavedViewsQuery";
function CustomersPage() {
restoreCustomerFilters();
const { org } = useOrg();
const { isInitialized } = useCustomerFilters();
const {
customers,
isLoading: customersLoading,
isFetchingUncached,
} = useCusSearchQuery();
usePersistedFilters();
const { isLoading: productsLoading } = useProductsQuery();
const resetProductStore = useProductStore((s) => s.reset);
@@ -34,7 +30,7 @@ function CustomersPage() {
useSavedViewsQuery();
useFullCusSearchQuery();
if (productsLoading || customersLoading) {
if (!isInitialized || productsLoading || customersLoading) {
return <LoadingScreen />;
}

View File

@@ -6,10 +6,10 @@ import {
DropdownMenuSubTrigger,
} from "@/components/v2/dropdowns/DropdownMenu";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { useCustomersQueryStates } from "../../hooks/useCustomersQueryStates";
import { useCustomerFilters } from "../../hooks/useCustomerFilters";
export const FilterStatusSubMenu = () => {
const { queryStates, setFilters } = useCustomersQueryStates();
const { queryStates, setFilters } = useCustomerFilters();
const statuses: string[] = [
"active",

View File

@@ -9,11 +9,11 @@ import {
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { cn } from "@/lib/utils";
import { getVersionCounts } from "@/utils/productUtils";
import { useCustomersQueryStates } from "../../hooks/useCustomersQueryStates";
import { useCustomerFilters } from "../../hooks/useCustomerFilters";
export const ProductsSubMenu = () => {
const { products } = useProductsQuery();
const { queryStates, setFilters } = useCustomersQueryStates();
const { queryStates, setFilters } = useCustomerFilters();
const versionCounts = getVersionCounts(products);
const selectedVersions = queryStates.version;

View File

@@ -15,7 +15,7 @@ import {
} from "@/components/ui/popover";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr } from "@/utils/genUtils";
import { useCustomersQueryStates } from "../../hooks/useCustomersQueryStates";
import { useCustomerFilters } from "../../hooks/useCustomerFilters";
interface SavedView {
id: string;
@@ -33,7 +33,7 @@ export const SavedViews = ({
mutateViews: any;
setDropdownOpen: (open: boolean) => void;
}) => {
const { setFilters } = useCustomersQueryStates();
const { setFilters } = useCustomerFilters();
const axiosInstance = useAxiosInstance();
const [deletingViewId, setDeletingViewId] = useState<string | null>(null);

View File

@@ -52,6 +52,7 @@ export const AnalyticsView = () => {
bcExclusionFlag,
groupBy,
truncated,
entityNames,
} = useAnalyticsData({ hasCleared });
// Show toast when data is truncated due to too many unique group values
@@ -140,10 +141,11 @@ export const AnalyticsView = () => {
features,
groupBy,
originalColors: colors,
entityNames,
});
return { chartData: transformed, chartConfig: config };
}, [events, features, groupBy, groupFilter]);
}, [events, features, groupBy, groupFilter, entityNames]);
useEffect(() => {
if (error?.response?.data?.code === ErrCode.ClickHouseDisabled) {
@@ -226,6 +228,7 @@ export const AnalyticsView = () => {
groupFilter,
setGroupFilter,
availableGroupValues,
entityNames,
}}
>
<div className="flex flex-col gap-4 h-full relative w-full text-sm pb-8 max-w-5xl mx-auto px-4 sm:px-10 pt-4 sm:pt-8">

View File

@@ -183,9 +183,7 @@ export const QueryTopbar = () => {
</DropdownMenuContent>
</DropdownMenu>
<SelectFeatureDropdown />
{propertyKeys && propertyKeys.length > 0 && (
<SelectGroupByDropdown propertyKeys={propertyKeys} />
)}
<SelectGroupByDropdown propertyKeys={propertyKeys ?? []} />
</div>
);
};

View File

@@ -1,6 +1,10 @@
import { CaretDownIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import {
CaretDownIcon,
MagnifyingGlassIcon,
PencilSimpleIcon,
} from "@phosphor-icons/react";
import { Check } from "lucide-react";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import { IconButton } from "@/components/v2/buttons/IconButton";
import {
@@ -26,12 +30,13 @@ export const SelectGroupByDropdown = ({
const navigate = useNavigate();
const location = useLocation();
const { groupFilter, setGroupFilter, availableGroupValues } =
const { groupFilter, setGroupFilter, availableGroupValues, entityNames } =
useAnalyticsContext();
const currentGroupBy = searchParams.get("group_by") || "";
const customerId = searchParams.get("customer_id");
const showCustomerIdOption = !customerId;
const maxGroups = Number(searchParams.get("max_groups")) || 10;
const updateQueryParams = ({ groupBy }: { groupBy: string | null }) => {
const params = new URLSearchParams(location.search);
@@ -40,11 +45,19 @@ export const SelectGroupByDropdown = ({
params.set("group_by", groupBy);
} else {
params.delete("group_by");
params.delete("max_groups");
}
navigate(`${location.pathname}?${params.toString()}`);
};
const updateMaxGroups = ({ value }: { value: number }) => {
const clamped = Math.min(250, Math.max(1, value));
const params = new URLSearchParams(location.search);
params.set("max_groups", String(clamped));
navigate(`${location.pathname}?${params.toString()}`);
};
const filteredOptions = propertyKeys.filter((key) =>
key.toLowerCase().includes(searchValue.toLowerCase()),
);
@@ -54,7 +67,34 @@ export const SelectGroupByDropdown = ({
setOpen(false);
};
const displayValue = currentGroupBy || "No grouping";
const [editingMaxGroups, setEditingMaxGroups] = useState(false);
const [maxGroupsDraft, setMaxGroupsDraft] = useState(String(maxGroups));
const maxGroupsInputRef = useRef<HTMLInputElement>(null);
// Sync draft when maxGroups changes externally
useEffect(() => {
if (!editingMaxGroups) {
setMaxGroupsDraft(String(maxGroups));
}
}, [maxGroups, editingMaxGroups]);
// Focus input when entering edit mode
useEffect(() => {
if (editingMaxGroups) {
maxGroupsInputRef.current?.focus();
maxGroupsInputRef.current?.select();
}
}, [editingMaxGroups]);
const commitMaxGroups = () => {
const val = Number.parseInt(maxGroupsDraft, 10);
if (!Number.isNaN(val) && val !== maxGroups) {
updateMaxGroups({ value: val });
} else {
setMaxGroupsDraft(String(maxGroups));
}
setEditingMaxGroups(false);
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
@@ -139,6 +179,51 @@ export const SelectGroupByDropdown = ({
</DropdownMenuItem>
))}
{/* Max groups - only shown when a groupBy is selected */}
{currentGroupBy && (
<>
<DropdownMenuSeparator />
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-xs text-t3">Max groups</span>
{editingMaxGroups ? (
<input
ref={maxGroupsInputRef}
type="number"
value={maxGroupsDraft}
min={1}
max={250}
onChange={(e) => setMaxGroupsDraft(e.target.value)}
onBlur={commitMaxGroups}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") {
commitMaxGroups();
}
if (e.key === "Escape") {
setMaxGroupsDraft(String(maxGroups));
setEditingMaxGroups(false);
}
}}
className="w-12 text-center text-xs bg-transparent border border-border rounded px-1 py-0.5 outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
) : (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setEditingMaxGroups(true);
}}
className="flex items-center gap-1 text-xs text-t2 hover:text-t1"
>
{maxGroups}
<PencilSimpleIcon size={10} className="text-t4" />
</button>
)}
</div>
</>
)}
{/* Filter section - only shown when a groupBy is selected */}
{currentGroupBy && availableGroupValues.length > 0 && (
<>
@@ -153,20 +238,26 @@ export const SelectGroupByDropdown = ({
<span className="text-xs">All values</span>
{!groupFilter && <Check className="ml-2 h-3 w-3 text-t3" />}
</DropdownMenuItem>
{availableGroupValues.map((value) => (
<DropdownMenuItem
key={value}
onClick={() => setGroupFilter(value)}
className="flex items-center justify-between"
>
<span className="text-xs font-mono truncate max-w-[150px]">
{value}
</span>
{groupFilter === value && (
<Check className="ml-2 h-3 w-3 text-t3 shrink-0" />
)}
</DropdownMenuItem>
))}
{availableGroupValues.map((value: string) => {
const displayValue =
value === "AUTUMN_RESERVED"
? "Other values"
: (entityNames?.[value] ?? value);
return (
<DropdownMenuItem
key={value}
onClick={() => setGroupFilter(value)}
className="flex items-center justify-between"
>
<span className="text-xs font-mono truncate max-w-[150px]">
{displayValue}
</span>
{groupFilter === value && (
<Check className="ml-2 h-3 w-3 text-t3 shrink-0" />
)}
</DropdownMenuItem>
);
})}
</>
)}
</div>

View File

@@ -32,6 +32,7 @@ export const useAnalyticsData = ({
const interval = searchParams.get("interval");
const groupBy = searchParams.get("group_by");
const binSize = searchParams.get("bin_size");
const maxGroups = Number(searchParams.get("max_groups")) || 10;
const { eventNames: cachedEventNames } = useEventNames();
@@ -59,6 +60,7 @@ export const useAnalyticsData = ({
group_by: formattedGroupBy,
bin_size: binSize || undefined,
timezone,
max_groups: formattedGroupBy ? maxGroups : undefined,
};
const {
@@ -75,6 +77,7 @@ export const useAnalyticsData = ({
...selectedEventNames.sort(),
groupBy,
timezone,
String(maxGroups),
]),
queryFn: async () => {
const { data } = await axiosInstance.post("/query/events", postBody);
@@ -88,12 +91,14 @@ export const useAnalyticsData = ({
featuresLoading,
queryLoading,
events: data?.events,
error: error && (error as any)?.code === ErrCode.ClickHouseDisabled
? null
: error,
error:
error && (error as any)?.code === ErrCode.ClickHouseDisabled
? null
: error,
bcExclusionFlag: data?.bcExclusionFlag ?? false,
groupBy,
truncated: data?.truncated ?? false,
entityNames: (data?.entityNames as Record<string, string>) ?? undefined,
};
};
@@ -138,8 +143,9 @@ export const useRawAnalyticsData = () => {
featuresLoading,
queryLoading,
rawEvents: data?.rawEvents,
error: error && (error as any)?.code === ErrCode.ClickHouseDisabled
? null
: error,
error:
error && (error as any)?.code === ErrCode.ClickHouseDisabled
? null
: error,
};
};

View File

@@ -176,11 +176,13 @@ export function generateChartConfig({
features,
groupBy,
originalColors,
entityNames,
}: {
events: EventsData;
features: Feature[];
groupBy: string | null;
originalColors: string[];
entityNames?: Record<string, string>;
}): ChartSeriesConfig[] {
const colorsToUse = groupBy ? CHART_COLORS : originalColors;
@@ -213,8 +215,14 @@ export function generateChartConfig({
const groupValue = parts[parts.length - 1];
const featureName = getFeatureName({ key: featureKey, features });
const displayGroupValue =
groupValue === "AUTUMN_RESERVED" ? "Other values" : groupValue;
let displayGroupValue: string;
if (groupValue === "AUTUMN_RESERVED") {
displayGroupValue = "Other values";
} else if (entityNames?.[groupValue]) {
displayGroupValue = entityNames[groupValue];
} else {
displayGroupValue = groupValue;
}
config.push({
xKey: "period",

View File

@@ -3,10 +3,10 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useCustomersQueryStates } from "./useCustomersQueryStates";
import { useCustomerFilters } from "./useCustomerFilters";
export const useCusSearchQuery = () => {
const { queryStates } = useCustomersQueryStates();
const { queryStates, isInitialized } = useCustomerFilters();
const trimmedSearch = queryStates.q.trim();
const axiosInstance = useAxiosInstance();
@@ -48,6 +48,7 @@ export const useCusSearchQuery = () => {
trimmedSearch,
]),
queryFn: fetcher,
enabled: isInitialized,
placeholderData: keepPreviousData,
});

View File

@@ -0,0 +1,155 @@
import {
parseAsArrayOf,
parseAsBoolean,
parseAsInteger,
parseAsString,
useQueryStates,
} from "nuqs";
import { useCallback, useEffect, useState } from "react";
import { useOrg } from "@/hooks/common/useOrg";
import { useEnv } from "@/utils/envUtils";
const FILTERS_KEY_PREFIX = "autumn:customer-filters";
const FILTER_PARAM_KEYS = [
"q",
"status",
"version",
"none",
"page",
"pageSize",
] as const;
type PersistedCustomerFilters = {
status: string[];
version: string[];
none: boolean;
pageSize: number;
};
function getStorageKey({ orgId, env }: { orgId: string; env: string }) {
return `${FILTERS_KEY_PREFIX}:${orgId}:${env}`;
}
function getSavedFilters({
orgId,
env,
}: {
orgId: string;
env: string;
}): PersistedCustomerFilters | null {
try {
const stored = localStorage.getItem(getStorageKey({ orgId, env }));
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
function buildRestoredState({
filters,
}: {
filters: PersistedCustomerFilters | null;
}) {
return {
q: null,
status: filters?.status?.length ? filters.status : null,
version: filters?.version?.length ? filters.version : null,
none: filters?.none ? true : null,
page: null,
pageSize:
filters?.pageSize && filters.pageSize !== 50 ? filters.pageSize : null,
lastItemId: null,
};
}
export const useCustomerFilters = () => {
const { org } = useOrg();
const orgId = org?.id;
const env = useEnv();
const [queryStates, setQueryStates] = useQueryStates(
{
q: parseAsString.withDefault(""),
status: parseAsArrayOf(parseAsString).withDefault([]),
version: parseAsArrayOf(parseAsString).withDefault([]),
none: parseAsBoolean.withDefault(false),
page: parseAsInteger.withDefault(1),
pageSize: parseAsInteger.withDefault(50),
lastItemId: parseAsString.withDefault(""),
},
{
history: "replace",
},
);
const settleKey = orgId ? `${orgId}:${env}` : null;
const [settledKey, setSettledKey] = useState<string | null>(null);
const isInitialized = settledKey === settleKey;
useEffect(() => {
if (!settleKey) return;
if (settledKey === settleKey) return;
const isContextSwitch = settledKey !== null;
const routerState = window.history.state?.usr;
if (routerState?.preAppliedFilters) {
setSettledKey(settleKey);
return;
}
const currentParams = new URLSearchParams(window.location.search);
const hasUrlFilterParams = FILTER_PARAM_KEYS.some((key) =>
currentParams.has(key),
);
if (isContextSwitch || !hasUrlFilterParams) {
const filters = getSavedFilters({ orgId: orgId!, env });
setQueryStates(buildRestoredState({ filters })).then(() => {
setSettledKey(settleKey);
});
} else {
setSettledKey(settleKey);
}
}, [settleKey, settledKey, setQueryStates, orgId, env]);
const setFilters = useCallback(
(filters: Partial<Omit<typeof queryStates, "page" | "lastItemId">>) => {
setQueryStates({ ...filters, page: 1, lastItemId: "" });
},
[setQueryStates],
);
useEffect(() => {
const onCustomersPage = window.location.pathname.endsWith("/customers");
if (!orgId || !isInitialized || !onCustomersPage) return;
try {
localStorage.setItem(
getStorageKey({ orgId, env }),
JSON.stringify({
status: queryStates.status,
version: queryStates.version,
none: queryStates.none,
pageSize: queryStates.pageSize,
}),
);
} catch {}
}, [
orgId,
env,
isInitialized,
queryStates.status,
queryStates.version,
queryStates.none,
queryStates.pageSize,
]);
return {
queryStates,
setQueryStates,
setFilters,
isInitialized,
};
};

View File

@@ -1,44 +0,0 @@
import { debounce } from "lodash";
import {
parseAsArrayOf,
parseAsBoolean,
parseAsInteger,
parseAsString,
useQueryStates,
} from "nuqs";
import { useCallback, useEffect, useState } from "react";
export const useCustomersQueryStates = () => {
const [queryStates, setQueryStates] = useQueryStates(
{
q: parseAsString.withDefault(""),
status: parseAsArrayOf(parseAsString).withDefault([]),
version: parseAsArrayOf(parseAsString).withDefault([]),
none: parseAsBoolean.withDefault(false),
page: parseAsInteger.withDefault(1),
pageSize: parseAsInteger.withDefault(50),
lastItemId: parseAsString.withDefault(""),
},
{
history: "replace",
},
);
// Wrapper that resets pagination when filters change
const setFilters = useCallback(
(filters: Partial<Omit<typeof queryStates, "page" | "lastItemId">>) => {
setQueryStates({ ...filters, page: 1, lastItemId: "" });
},
[setQueryStates],
);
const [stableStates, setStableStates] = useState(queryStates);
useEffect(() => {
const debouncedSetStableStates = debounce((queryStates: any) => {
setStableStates(queryStates);
}, 50);
debouncedSetStableStates(queryStates);
}, [queryStates]);
return { queryStates: stableStates, setQueryStates, setFilters };
};

View File

@@ -2,12 +2,12 @@ import type { FullCustomer } from "@autumn/shared";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useCustomersQueryStates } from "./useCustomersQueryStates";
import { useCustomerFilters } from "./useCustomerFilters";
export const FULL_CUSTOMERS_QUERY_KEY = "full_customers";
export const useFullCusSearchQuery = () => {
const { queryStates } = useCustomersQueryStates();
const { queryStates, isInitialized } = useCustomerFilters();
const axiosInstance = useAxiosInstance();
const buildKey = useQueryKeyFactory();
@@ -41,6 +41,7 @@ export const useFullCusSearchQuery = () => {
return data;
},
enabled: isInitialized,
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});

View File

@@ -1,107 +0,0 @@
import { useEffect } from "react";
import { useOrg } from "@/hooks/common/useOrg";
import { useCustomersQueryStates } from "./useCustomersQueryStates";
const FILTERS_KEY = "autumn:customer-filters";
const ORG_KEY = "autumn_org";
type PersistedCustomerFilters = {
status: string[];
version: string[];
none: boolean;
pageSize: number;
};
function getSavedFilters({
orgId,
}: {
orgId: string;
}): PersistedCustomerFilters | null {
try {
const stored = localStorage.getItem(FILTERS_KEY);
if (!stored) return null;
return JSON.parse(stored)[orgId] ?? null;
} catch {
return null;
}
}
let restoredForOrg: string | null = null;
/** Call synchronously at the top of CustomersPage render, before any hooks. On each render, checks whether the current org differs from the last-restored org and, if so, replaces URL params with that org's saved filters from localStorage (or clears them). This ensures stale params from a previous org are never carried over. Skips restoration when the navigation carried `preAppliedFilters` state (e.g. clicking active customers on the products page). */
export function restoreCustomerFilters() {
try {
const orgData = localStorage.getItem(ORG_KEY);
if (!orgData) return;
const { id: orgId } = JSON.parse(orgData);
// Already restored for this org — nothing to do.
if (restoredForOrg === orgId) return;
restoredForOrg = orgId;
// React Router stores navigation state under `usr` in history state.
// If the navigation explicitly set filters, don't overwrite them with localStorage.
const routerState = window.history.state?.usr;
if (routerState?.preAppliedFilters) return;
const filters = getSavedFilters({ orgId });
const params = new URLSearchParams();
if (filters?.status?.length)
params.set("status", filters.status.join(","));
if (filters?.version?.length)
params.set("version", filters.version.join(","));
if (filters?.none) params.set("none", "true");
if (filters?.pageSize && filters.pageSize !== 50)
params.set("pageSize", String(filters.pageSize));
const paramString = params.toString();
const newUrl = paramString
? `${window.location.pathname}?${paramString}`
: window.location.pathname;
window.history.replaceState(null, "", newUrl);
} catch {}
}
/** Persists current filter queryStates to localStorage whenever they change. */
export function usePersistedFilters() {
const { org } = useOrg();
const { queryStates } = useCustomersQueryStates();
const orgId = org?.id;
// Reset the module-level flag when the component unmounts so that
// navigating back to the customers page re-reads from localStorage.
useEffect(() => {
return () => {
restoredForOrg = null;
};
}, []);
// Persist current filters to localStorage keyed by org
useEffect(() => {
if (!orgId) return;
try {
const stored = localStorage.getItem(FILTERS_KEY);
const map: Record<string, PersistedCustomerFilters> = stored
? JSON.parse(stored)
: {};
map[orgId] = {
status: queryStates.status,
version: queryStates.version,
none: queryStates.none,
pageSize: queryStates.pageSize,
};
localStorage.setItem(FILTERS_KEY, JSON.stringify(map));
} catch {}
}, [
orgId,
queryStates.status,
queryStates.version,
queryStates.none,
queryStates.pageSize,
]);
}

View File

@@ -0,0 +1,291 @@
import type {
AutoTopup,
DbSpendLimit,
DbUsageAlert,
Entity,
Feature,
FullCustomer,
} from "@autumn/shared";
import { FadersHorizontalIcon, GavelIcon } from "@phosphor-icons/react";
import { type ReactNode, useMemo } from "react";
import { Table } from "@/components/general/table";
import { SectionTag } from "@/components/v2/badges/SectionTag";
import { cn } from "@/lib/utils";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
import { useCustomerContext } from "../customer/CustomerContext";
import { EmptyState } from "./table/EmptyState";
const pillClassName =
"rounded-md bg-muted px-1.5 py-0.5 text-xs text-t3 whitespace-nowrap";
const rowClassName =
"flex items-center gap-2 rounded-lg border bg-interactive-secondary h-12 px-3 min-w-0";
const getFeatureLabel = ({
featureId,
featureNameById,
}: {
featureId?: string;
featureNameById: Map<string, string>;
}) => {
if (!featureId) return "All features";
return featureNameById.get(featureId) ?? featureId;
};
const StatusPill = ({ enabled }: { enabled: boolean }) => (
<span
className={cn(
"shrink-0 rounded-md px-1.5 py-0.5 text-xs font-medium",
enabled ? "bg-green-500/10 text-green-600" : "bg-muted text-t3",
)}
>
{enabled ? "Enabled" : "Disabled"}
</span>
);
const Pill = ({
children,
className,
}: {
children: ReactNode;
className?: string;
}) => <span className={cn(pillClassName, className)}>{children}</span>;
const BillingControlsGroup = ({
title,
children,
emptyText,
hasItems,
}: {
title: string;
children: ReactNode;
emptyText: string;
hasItems: boolean;
}) => (
<div className="flex flex-col">
<SectionTag>{title}</SectionTag>
{hasItems ? (
children
) : (
<EmptyState className="h-12 min-h-0" text={emptyText} />
)}
</div>
);
const AutoTopupRow = ({
autoTopup,
featureNameById,
}: {
autoTopup: AutoTopup;
featureNameById: Map<string, string>;
}) => {
const purchaseLimit = autoTopup.purchase_limit;
return (
<div className={rowClassName}>
<StatusPill enabled={autoTopup.enabled} />
<span className="truncate text-sm text-t1 font-medium">
{getFeatureLabel({
featureId: autoTopup.feature_id,
featureNameById,
})}
</span>
<div className="ml-auto flex items-center gap-1.5 shrink-0">
<Pill>Threshold: {autoTopup.threshold.toLocaleString()}</Pill>
<Pill>Qty: {autoTopup.quantity.toLocaleString()}</Pill>
{purchaseLimit && (
<Pill className="hidden lg:inline">
Limit: {purchaseLimit.limit}/{purchaseLimit.interval}
</Pill>
)}
</div>
</div>
);
};
const SpendLimitRow = ({
spendLimit,
featureNameById,
}: {
spendLimit: DbSpendLimit;
featureNameById: Map<string, string>;
}) => (
<div className={rowClassName}>
<StatusPill enabled={spendLimit.enabled} />
<span className="truncate text-sm text-t1 font-medium">
{getFeatureLabel({
featureId: spendLimit.feature_id,
featureNameById,
})}
</span>
<div className="ml-auto flex items-center gap-1.5 shrink-0">
<Pill>
Overage limit:{" "}
{spendLimit.overage_limit === undefined
? "none"
: spendLimit.overage_limit.toLocaleString()}
</Pill>
</div>
</div>
);
const UsageAlertRow = ({
usageAlert,
featureNameById,
}: {
usageAlert: DbUsageAlert;
featureNameById: Map<string, string>;
}) => {
const thresholdLabel =
usageAlert.threshold_type === "usage_percentage"
? `${usageAlert.threshold}%`
: usageAlert.threshold.toLocaleString();
return (
<div className={rowClassName}>
<StatusPill enabled={usageAlert.enabled} />
<span className="truncate text-sm text-t1 font-medium">
{getFeatureLabel({
featureId: usageAlert.feature_id,
featureNameById,
})}
</span>
{usageAlert.name && (
<span className="truncate text-xs text-t3 font-mono ml-4">{usageAlert.name}</span>
)}
<div className="ml-auto flex items-center gap-1.5 shrink-0">
<Pill>At: {thresholdLabel}</Pill>
<Pill className="hidden sm:inline">
{usageAlert.threshold_type === "usage_percentage"
? "% used of allowance"
: "absolute usage"}
</Pill>
</div>
</div>
);
};
export function CustomerBillingControlsSection() {
const { customer, features, isLoading } = useCusQuery();
const { entityId } = useCustomerContext();
const fullCustomer = customer as FullCustomer | undefined;
const selectedEntity = useMemo(() => {
if (!entityId) return null;
return (
fullCustomer?.entities.find(
(entity: Entity) =>
entity.id === entityId || entity.internal_id === entityId,
) ?? null
);
}, [entityId, fullCustomer?.entities]);
const featureNameById = useMemo(() => {
return new Map(
(features ?? []).map((feature: Feature) => [feature.id, feature.name]),
);
}, [features]);
const autoTopups = selectedEntity ? [] : (fullCustomer?.auto_topups ?? []);
const spendLimits = selectedEntity
? (selectedEntity.spend_limits ?? [])
: (fullCustomer?.spend_limits ?? []);
const usageAlerts = selectedEntity
? (selectedEntity.usage_alerts ?? [])
: (fullCustomer?.usage_alerts ?? []);
const hasAnyControls =
autoTopups.length > 0 || spendLimits.length > 0 || usageAlerts.length > 0;
const entitiesWithControlsCount =
fullCustomer?.entities?.filter(
(entity: Entity) =>
(entity.spend_limits?.length ?? 0) > 0 ||
(entity.usage_alerts?.length ?? 0) > 0,
).length ?? 0;
if (!isLoading && !hasAnyControls && selectedEntity) return null;
const customerEmptyText =
entitiesWithControlsCount > 0
? `No customer-level billing controls — billing controls exist on ${entitiesWithControlsCount} ${entitiesWithControlsCount === 1 ? "entity" : "entities"}`
: "No billing controls configured";
return (
<Table.Container>
<Table.Toolbar>
<Table.Heading>
<GavelIcon
size={16}
weight="fill"
className="text-subtle"
/>
Billing controls
</Table.Heading>
</Table.Toolbar>
{isLoading ? (
<EmptyState text="Loading billing controls" />
) : !hasAnyControls ? (
<EmptyState text={customerEmptyText} />
) : (
<div className="flex flex-col gap-4">
{autoTopups.length > 0 && (
<BillingControlsGroup
title="Auto top-ups"
emptyText=""
hasItems
>
<div className="flex flex-col gap-1.5">
{autoTopups.map((autoTopup) => (
<AutoTopupRow
key={`auto-topup-${autoTopup.feature_id}`}
autoTopup={autoTopup}
featureNameById={featureNameById}
/>
))}
</div>
</BillingControlsGroup>
)}
{spendLimits.length > 0 && (
<BillingControlsGroup
title="Spend limits"
emptyText=""
hasItems
>
<div className="flex flex-col gap-1.5">
{spendLimits.map((spendLimit, index) => (
<SpendLimitRow
key={`spend-limit-${spendLimit.feature_id ?? "global"}-${index}`}
spendLimit={spendLimit}
featureNameById={featureNameById}
/>
))}
</div>
</BillingControlsGroup>
)}
{usageAlerts.length > 0 && (
<BillingControlsGroup
title="Usage alerts"
emptyText=""
hasItems
>
<div className="flex flex-col gap-1.5">
{usageAlerts.map((usageAlert, index) => (
<UsageAlertRow
key={`usage-alert-${usageAlert.feature_id ?? "global"}-${usageAlert.name ?? index}`}
usageAlert={usageAlert}
featureNameById={featureNameById}
/>
))}
</div>
</BillingControlsGroup>
)}
</div>
)}
</Table.Container>
);
}

View File

@@ -1,121 +0,0 @@
import { PurchaseLimitInterval } from "@autumn/shared";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/v2/selects/Select";
import type { BalanceEditFormInstance } from "./useBalanceEditForm";
const RATE_LIMIT_INTERVALS = [
{ value: PurchaseLimitInterval.Hour, label: "Hour" },
{ value: PurchaseLimitInterval.Day, label: "Day" },
{ value: PurchaseLimitInterval.Week, label: "Week" },
{ value: PurchaseLimitInterval.Month, label: "Month" },
];
export function AutoTopUpSection({ form }: { form: BalanceEditFormInstance }) {
return (
<div className="flex flex-col gap-3">
<form.AppField name="autoTopUp.enabled">
{(field) => (
<field.AreaCheckboxField
title="Auto Top-Up"
description="Automatically purchase more credits when balance drops below a threshold."
/>
)}
</form.AppField>
<form.Field name="autoTopUp.enabled">
{(enabledField) =>
enabledField.state.value && (
<>
<div className="grid grid-cols-2 gap-3">
<form.AppField name="autoTopUp.threshold">
{(field) => (
<field.NumberField
label="Threshold"
description="Balance level that triggers a top-up"
placeholder="e.g. 10"
min={0}
float
/>
)}
</form.AppField>
<form.AppField name="autoTopUp.quantity">
{(field) => (
<field.NumberField
label="Quantity"
description="Credits added per top-up"
placeholder="e.g. 100"
min={1}
float
/>
)}
</form.AppField>
</div>
<form.AppField name="autoTopUp.maxPurchasesEnabled">
{(field) => (
<field.AreaCheckboxField
title="Rate Limit"
description="Limit how many auto top-ups can occur in a given interval."
/>
)}
</form.AppField>
<form.Field name="autoTopUp.maxPurchasesEnabled">
{(maxField) =>
maxField.state.value && (
<div className="grid grid-cols-2 gap-3">
<form.Field name="autoTopUp.interval">
{(field) => (
<div>
<div className="text-form-label block mb-1">
Interval
</div>
<p className="text-t3 text-xs mb-1">
Rate limit reset period
</p>
<Select
value={field.state.value}
onValueChange={(v) =>
field.handleChange(v as PurchaseLimitInterval)
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{RATE_LIMIT_INTERVALS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</form.Field>
<form.AppField name="autoTopUp.maxPurchases">
{(field) => (
<field.NumberField
label="Max Purchases"
description="Top-ups allowed per interval"
placeholder="e.g. 5"
min={1}
/>
)}
</form.AppField>
</div>
)
}
</form.Field>
</>
)
}
</form.Field>
</div>
);
}

View File

@@ -1,12 +1,9 @@
import {
type AutoTopup,
computeGrantedBalanceInput,
type Entity,
type FullCusProduct,
type FullCustomerEntitlement,
type FullCustomerPrice,
isOneOffPrice,
isPrepaidPrice,
isUnlimitedCusEnt,
numberWithCommas,
} from "@autumn/shared";
@@ -23,14 +20,12 @@ import { LabelInput } from "@/components/v2/inputs/LabelInput";
import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet";
import { useCustomerBalanceSheetStore } from "@/hooks/stores/useCustomerBalanceSheetStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { CusService } from "@/services/customers/CusService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { getBackendErr, notNullish } from "@/utils/genUtils";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
import { useCustomerContext } from "../../customer/CustomerContext";
import { AutoTopUpSection } from "./AutoTopUpSection";
import { BalanceEditPreviews } from "./BalanceEditPreviews";
import { GrantedBalancePopover } from "./GrantedBalancePopover";
import {
@@ -80,19 +75,6 @@ export function BalanceEditSheet() {
cp.price.entitlement_id === selectedCusEnt.entitlement.id,
);
const hasOneOffPrepaidPrice = cusPrice
? isOneOffPrice(cusPrice.price) && isPrepaidPrice(cusPrice.price)
: false;
const hasExistingAutoTopUp = customer?.auto_topups?.some(
(c: AutoTopup) => c.feature_id === featureId,
);
const isEligibleForAutoTopUp =
hasOneOffPrepaidPrice || !!hasExistingAutoTopUp;
const existingAutoTopUp =
customer?.auto_topups?.find((c: AutoTopup) => c.feature_id === featureId) ??
null;
return (
<div className="flex flex-col h-full">
<SheetHeader
@@ -119,8 +101,6 @@ export function BalanceEditSheet() {
cusProduct={cusProduct}
cusPrice={cusPrice}
featureId={featureId}
existingAutoTopUp={existingAutoTopUp}
isEligibleForAutoTopUp={isEligibleForAutoTopUp}
/>
)}
</div>
@@ -161,8 +141,6 @@ function BalanceEditForm({
cusProduct,
cusPrice,
featureId,
existingAutoTopUp,
isEligibleForAutoTopUp,
}: {
selectedCusEnt: FullCustomerEntitlement;
entityId: string | null;
@@ -170,13 +148,10 @@ function BalanceEditForm({
cusProduct: FullCusProduct | undefined;
cusPrice: FullCustomerPrice | undefined;
featureId: string;
existingAutoTopUp: AutoTopup | null;
isEligibleForAutoTopUp: boolean;
}) {
const form = useBalanceEditForm({
selectedCusEnt,
entityId,
existingAutoTopUp,
});
return (
@@ -190,7 +165,7 @@ function BalanceEditForm({
/>
</SheetSection>
<SheetSection withSeparator={isEligibleForAutoTopUp}>
<SheetSection withSeparator={false}>
<BalanceFields
form={form}
selectedCusEnt={selectedCusEnt}
@@ -198,12 +173,6 @@ function BalanceEditForm({
/>
</SheetSection>
{isEligibleForAutoTopUp && (
<SheetSection withSeparator={false}>
<AutoTopUpSection form={form} />
</SheetSection>
)}
<SubmitButton
form={form}
customer={customer}
@@ -550,40 +519,6 @@ function SubmitButton({
}
}
// Queue auto top-up update
if (hasAutoTopUpChanges({ form })) {
const autoTopUp = values.autoTopUp;
const newConfig: AutoTopup = {
feature_id: featureId,
enabled: autoTopUp.enabled,
threshold: autoTopUp.threshold ?? 0,
quantity: autoTopUp.quantity ?? 1,
...(autoTopUp.enabled &&
autoTopUp.maxPurchasesEnabled && {
purchase_limit: {
interval: autoTopUp.interval,
limit: autoTopUp.maxPurchases ?? 1,
},
}),
};
const otherConfigs = (customer.auto_topups ?? []).filter(
(c: AutoTopup) => c.feature_id !== featureId,
);
promises.push(
CusService.updateCustomer({
axios: axiosInstance,
customer_id: customer.id || customer.internal_id,
data: {
billing_controls: {
auto_topups: [...otherConfigs, newConfig],
},
},
}),
);
}
await Promise.all(promises);
toast.success("Updated successfully");
handleClose();
@@ -630,20 +565,3 @@ function hasBalanceChanges({
);
}
function hasAutoTopUpChanges({
form,
}: {
form: BalanceEditFormInstance;
}): boolean {
const meta = form.state.fieldMeta;
return (
meta["autoTopUp.enabled"]?.isDirty ||
meta["autoTopUp.threshold"]?.isDirty ||
meta["autoTopUp.quantity"]?.isDirty ||
meta["autoTopUp.maxPurchasesEnabled"]?.isDirty ||
meta["autoTopUp.interval"]?.isDirty ||
meta["autoTopUp.maxPurchases"]?.isDirty ||
false
);
}

View File

@@ -1,4 +1,3 @@
import { PurchaseLimitInterval } from "@autumn/shared";
import { z } from "zod/v4";
export const BalanceEditFormSchema = z
@@ -8,17 +7,9 @@ export const BalanceEditFormSchema = z
grantedAndPurchasedBalance: z.number().nullable(),
nextResetAt: z.number().nullable(),
addValue: z.number().nullable(),
autoTopUp: z.object({
enabled: z.boolean(),
threshold: z.number().min(0).nullable(),
quantity: z.number().min(1).nullable(),
maxPurchasesEnabled: z.boolean(),
interval: z.enum(PurchaseLimitInterval),
maxPurchases: z.number().min(1).nullable(),
}),
})
.check((ctx) => {
const { mode, balance, addValue, autoTopUp } = ctx.value;
const { mode, balance, addValue } = ctx.value;
if (mode === "set" && balance === null) {
ctx.issues.push({
@@ -37,35 +28,6 @@ export const BalanceEditFormSchema = z
input: addValue,
});
}
if (autoTopUp.enabled) {
if (autoTopUp.threshold === null || autoTopUp.threshold < 0) {
ctx.issues.push({
code: "custom",
message: "Threshold must be 0 or above",
path: ["autoTopUp", "threshold"],
input: autoTopUp.threshold,
});
}
if (autoTopUp.quantity === null || autoTopUp.quantity < 1) {
ctx.issues.push({
code: "custom",
message: "Quantity must be 1 or above",
path: ["autoTopUp", "quantity"],
input: autoTopUp.quantity,
});
}
if (autoTopUp.maxPurchasesEnabled) {
if (autoTopUp.maxPurchases === null || autoTopUp.maxPurchases < 1) {
ctx.issues.push({
code: "custom",
message: "Max purchases must be 1 or above",
path: ["autoTopUp", "maxPurchases"],
input: autoTopUp.maxPurchases,
});
}
}
}
});
export type BalanceEditForm = z.infer<typeof BalanceEditFormSchema>;

View File

@@ -1,11 +1,9 @@
import {
type AutoTopup,
cusEntsToBalance,
cusEntsToGrantedBalance,
cusEntsToPrepaidQuantity,
type FullCusEntWithFullCusProduct,
nullish,
PurchaseLimitInterval,
} from "@autumn/shared";
import { useAppForm } from "@/hooks/form/form";
import {
@@ -16,11 +14,9 @@ import {
export function useBalanceEditForm({
selectedCusEnt,
entityId,
existingAutoTopUp,
}: {
selectedCusEnt: FullCusEntWithFullCusProduct;
entityId: string | null;
existingAutoTopUp: AutoTopup | null;
}) {
const prepaidAllowance = cusEntsToPrepaidQuantity({
cusEnts: [selectedCusEnt],
@@ -47,16 +43,6 @@ export function useBalanceEditForm({
grantedAndPurchasedBalance: grantedAndPurchasedBalance ?? null,
nextResetAt: selectedCusEnt.next_reset_at ?? null,
addValue: null,
autoTopUp: {
enabled: existingAutoTopUp?.enabled ?? false,
threshold: existingAutoTopUp?.threshold ?? null,
quantity: existingAutoTopUp?.quantity ?? null,
maxPurchasesEnabled: !!existingAutoTopUp?.purchase_limit,
interval:
existingAutoTopUp?.purchase_limit?.interval ??
PurchaseLimitInterval.Month,
maxPurchases: existingAutoTopUp?.purchase_limit?.limit ?? null,
},
} as BalanceEditForm,
validators: {
onChange: BalanceEditFormSchema,

View File

@@ -1,7 +1,20 @@
export const EmptyState = ({ text }: { text: string | React.ReactNode }) => {
import { cn } from "@/lib/utils";
export const EmptyState = ({
text,
className,
}: {
text: string | React.ReactNode;
className?: string;
}) => {
return (
<div className="flex justify-center items-center py-4 border-dashed border rounded-lg h-13 w-full bg-interactive-secondary dark:bg-transparent shadow-sm dark:shadow-none">
<span className="text-xs text-t4">{text}</span>
<div
className={cn(
"flex justify-center items-center py-4 border-dashed border rounded-lg h-13 w-full min-w-0 overflow-hidden px-4",
className,
)}
>
<span className="text-xs text-t4 truncate">{text}</span>
</div>
);
};

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