Merge branch 'main' into dev
This commit is contained in:
8
.github/workflows/sdk-publish.yml
vendored
8
.github/workflows/sdk-publish.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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
|
||||
|
||||
65
server/src/internal/analytics/actions/getEntityNames.ts
Normal file
65
server/src/internal/analytics/actions/getEntityNames.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
})),
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ?? {}),
|
||||
};
|
||||
};
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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" },
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
256
server/tests/integration/billing/attach/attach-metadata.test.ts
Normal file
256
server/tests/integration/billing/attach/attach-metadata.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface BillingContext {
|
||||
billingVersion: BillingVersion;
|
||||
successUrl?: string;
|
||||
checkoutSessionParams?: Record<string, unknown>;
|
||||
userMetadata?: Record<string, string>;
|
||||
|
||||
skipBillingChanges?: boolean;
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export type TimeseriesEventsParams = TotalEventsParams & {
|
||||
no_count?: boolean;
|
||||
timezone?: string;
|
||||
enforceGroupLimit?: boolean;
|
||||
max_groups?: number;
|
||||
};
|
||||
|
||||
export type CalculateDateRangeParams = Omit<
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -183,9 +183,7 @@ export const QueryTopbar = () => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<SelectFeatureDropdown />
|
||||
{propertyKeys && propertyKeys.length > 0 && (
|
||||
<SelectGroupByDropdown propertyKeys={propertyKeys} />
|
||||
)}
|
||||
<SelectGroupByDropdown propertyKeys={propertyKeys ?? []} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
155
vite/src/views/customers/hooks/useCustomerFilters.ts
Normal file
155
vite/src/views/customers/hooks/useCustomerFilters.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user