diff --git a/.gitignore b/.gitignore index ea19efba48..8ddc5610a4 100644 --- a/.gitignore +++ b/.gitignore @@ -157,6 +157,7 @@ server/.turbo notes.txt server/experiments/results/* !server/experiments/results/.gitkeep +server/experiments/out/getFullSubject-* # Leaf contract fixtures are synced from private S3. apps/leaf/contracts/ diff --git a/ai b/ai index bce748efa3..ae59fe577c 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit bce748efa378d055b018b0f59251f44ecd8e06c4 +Subproject commit ae59fe577cb20936b38e2b853c961e7753bd30b6 diff --git a/apps/docs/mintlify/api-reference/billing/attach.mdx b/apps/docs/mintlify/api-reference/billing/attach.mdx index b9e63ab078..4ac976756f 100644 --- a/apps/docs/mintlify/api-reference/billing/attach.mdx +++ b/apps/docs/mintlify/api-reference/billing/attach.mdx @@ -506,7 +506,7 @@ A customer who has paid is locked to their currency: passing a different one, or - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -520,6 +520,10 @@ A customer who has paid is locked to their currency: passing a different one, or Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx b/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx index 7993df4138..6339625538 100644 --- a/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx +++ b/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx @@ -467,7 +467,7 @@ const response = await autumn.billing.update({ - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -481,6 +481,10 @@ const response = await autumn.billing.update({ Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/billing/createSchedule.mdx b/apps/docs/mintlify/api-reference/billing/createSchedule.mdx index 8d7ca36efd..3d02e5cdc0 100644 --- a/apps/docs/mintlify/api-reference/billing/createSchedule.mdx +++ b/apps/docs/mintlify/api-reference/billing/createSchedule.mdx @@ -459,7 +459,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -473,6 +473,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/billing/import.mdx b/apps/docs/mintlify/api-reference/billing/import.mdx index 3520c0bebe..4acf8559d6 100644 --- a/apps/docs/mintlify/api-reference/billing/import.mdx +++ b/apps/docs/mintlify/api-reference/billing/import.mdx @@ -982,7 +982,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -996,6 +996,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1917,7 +1921,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1931,6 +1935,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/billing/multiAttach.mdx b/apps/docs/mintlify/api-reference/billing/multiAttach.mdx index a6309cbbab..cb9a728718 100644 --- a/apps/docs/mintlify/api-reference/billing/multiAttach.mdx +++ b/apps/docs/mintlify/api-reference/billing/multiAttach.mdx @@ -377,7 +377,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -391,6 +391,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/billing/previewAttach.mdx b/apps/docs/mintlify/api-reference/billing/previewAttach.mdx index d8de8d897b..33de9a9ab8 100644 --- a/apps/docs/mintlify/api-reference/billing/previewAttach.mdx +++ b/apps/docs/mintlify/api-reference/billing/previewAttach.mdx @@ -433,7 +433,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -447,6 +447,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1687,7 +1691,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1701,6 +1705,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -2600,7 +2608,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -2614,6 +2622,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/billing/previewMultiAttach.mdx b/apps/docs/mintlify/api-reference/billing/previewMultiAttach.mdx index 295ea5f2d4..be17f6b5db 100644 --- a/apps/docs/mintlify/api-reference/billing/previewMultiAttach.mdx +++ b/apps/docs/mintlify/api-reference/billing/previewMultiAttach.mdx @@ -377,7 +377,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -391,6 +391,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1395,7 +1399,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1409,6 +1413,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -2308,7 +2316,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -2322,6 +2330,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/billing/previewMultiUpdate.mdx b/apps/docs/mintlify/api-reference/billing/previewMultiUpdate.mdx index c735a08078..a191ff6e26 100644 --- a/apps/docs/mintlify/api-reference/billing/previewMultiUpdate.mdx +++ b/apps/docs/mintlify/api-reference/billing/previewMultiUpdate.mdx @@ -822,7 +822,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -836,6 +836,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1735,7 +1739,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1749,6 +1753,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx b/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx index 5d8231c9ca..995a75ccd2 100644 --- a/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx +++ b/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx @@ -433,7 +433,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -447,6 +447,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1630,7 +1634,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1644,6 +1648,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -2543,7 +2551,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -2557,6 +2565,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/billing/setupPayment.mdx b/apps/docs/mintlify/api-reference/billing/setupPayment.mdx index 263b7b763f..925b8aa096 100644 --- a/apps/docs/mintlify/api-reference/billing/setupPayment.mdx +++ b/apps/docs/mintlify/api-reference/billing/setupPayment.mdx @@ -433,7 +433,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -447,6 +447,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/customers/getCustomer.mdx b/apps/docs/mintlify/api-reference/customers/getCustomer.mdx index 53451fc65a..1d3567eab6 100644 --- a/apps/docs/mintlify/api-reference/customers/getCustomer.mdx +++ b/apps/docs/mintlify/api-reference/customers/getCustomer.mdx @@ -801,7 +801,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -815,6 +815,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1736,7 +1740,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1750,6 +1754,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/customers/getOrCreateCustomer.mdx b/apps/docs/mintlify/api-reference/customers/getOrCreateCustomer.mdx index 48cb5d9af9..143d96ea4c 100644 --- a/apps/docs/mintlify/api-reference/customers/getOrCreateCustomer.mdx +++ b/apps/docs/mintlify/api-reference/customers/getOrCreateCustomer.mdx @@ -80,7 +80,7 @@ Pass `currency` to bill this customer in a currency other than your organization - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -94,6 +94,10 @@ Pass `currency` to bill this customer in a currency other than your organization Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1005,7 +1009,7 @@ Pass `currency` to bill this customer in a currency other than your organization - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1019,6 +1023,10 @@ Pass `currency` to bill this customer in a currency other than your organization Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1940,7 +1948,7 @@ Pass `currency` to bill this customer in a currency other than your organization - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1954,6 +1962,10 @@ Pass `currency` to bill this customer in a currency other than your organization Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/customers/listCustomers.mdx b/apps/docs/mintlify/api-reference/customers/listCustomers.mdx index 98e5d65318..bb1ab2543c 100644 --- a/apps/docs/mintlify/api-reference/customers/listCustomers.mdx +++ b/apps/docs/mintlify/api-reference/customers/listCustomers.mdx @@ -826,7 +826,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -840,6 +840,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1761,7 +1765,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1775,6 +1779,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/customers/updateCustomer.mdx b/apps/docs/mintlify/api-reference/customers/updateCustomer.mdx index 57248c330c..e099aa62ef 100644 --- a/apps/docs/mintlify/api-reference/customers/updateCustomer.mdx +++ b/apps/docs/mintlify/api-reference/customers/updateCustomer.mdx @@ -64,7 +64,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -78,6 +78,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -989,7 +993,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1003,6 +1007,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1924,7 +1932,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1938,6 +1946,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/entities/createEntity.mdx b/apps/docs/mintlify/api-reference/entities/createEntity.mdx index 8ba9db9165..e03d6b0939 100644 --- a/apps/docs/mintlify/api-reference/entities/createEntity.mdx +++ b/apps/docs/mintlify/api-reference/entities/createEntity.mdx @@ -181,7 +181,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -195,6 +195,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -922,7 +926,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -936,6 +940,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1856,7 +1864,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1870,6 +1878,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/entities/getEntity.mdx b/apps/docs/mintlify/api-reference/entities/getEntity.mdx index e7aef63475..030dd111cf 100644 --- a/apps/docs/mintlify/api-reference/entities/getEntity.mdx +++ b/apps/docs/mintlify/api-reference/entities/getEntity.mdx @@ -610,7 +610,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -624,6 +624,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1544,7 +1548,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1558,6 +1562,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/entities/listEntities.mdx b/apps/docs/mintlify/api-reference/entities/listEntities.mdx index 616a153f3a..4a5c6e2648 100644 --- a/apps/docs/mintlify/api-reference/entities/listEntities.mdx +++ b/apps/docs/mintlify/api-reference/entities/listEntities.mdx @@ -639,7 +639,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -653,6 +653,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1573,7 +1577,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1587,6 +1591,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/entities/updateEntity.mdx b/apps/docs/mintlify/api-reference/entities/updateEntity.mdx index a2dffea73f..75b1135483 100644 --- a/apps/docs/mintlify/api-reference/entities/updateEntity.mdx +++ b/apps/docs/mintlify/api-reference/entities/updateEntity.mdx @@ -712,7 +712,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -726,6 +726,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1646,7 +1650,7 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1660,6 +1664,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/invoices/insertInvoices.mdx b/apps/docs/mintlify/api-reference/invoices/insertInvoices.mdx new file mode 100644 index 0000000000..608a0756cf --- /dev/null +++ b/apps/docs/mintlify/api-reference/invoices/insertInvoices.mdx @@ -0,0 +1,143 @@ +--- +title: "Insert Invoices" +openapi: "openapi POST /v1/invoices.insert" +--- + +import { DynamicParamField } from "/snippets/dynamic-param-field.jsx"; +import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx"; +import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + +### Body Parameters + + + Invoices to insert or update, in response order. + + + The customer this invoice belongs to. + + + + Plan IDs represented by this invoice. + + + + The processor's stable invoice ID. + + + + The billing processor that owns this invoice. + + + + The invoice status. + + + + The invoice total in major currency units. + + + + The amount paid in major currency units. + + + + The refunded amount in major currency units. + + + + The currency code. Defaults to the organization's default currency. + + + + The invoice creation timestamp in milliseconds. + + + + The hosted invoice URL, when available. + + + + + + +### Response + + + Inserted or updated invoices in request order. + + + Array of plan IDs included in this invoice + + + + The Stripe invoice ID + + + + The billing processor that owns this invoice. + + + + The status of the invoice + + + + The total amount of the invoice + + + + The currency code for the invoice + + + + Timestamp when the invoice was created + + + + URL to the Stripe-hosted invoice page + + + + The Autumn invoice ID. + + + + The customer this invoice belongs to. + + + + The amount paid in major currency units. + + + + The refunded amount in major currency units. + + + + + + + +```json 200 +{ + "invoices": [ + { + "id": "inv_2b3c4d5e6f7g8h", + "customer_id": "cus_123", + "plan_ids": [ + "pro" + ], + "stripe_id": "in_legacy_123", + "processor_type": "stripe", + "status": "paid", + "total": 29.99, + "amount_paid": 29.99, + "refunded_amount": 0, + "currency": "usd", + "created_at": 1451606400000, + "hosted_invoice_url": "https://billing.example.com/invoices/legacy-123" + } + ] +} +``` + diff --git a/apps/docs/mintlify/api-reference/plans/createPlan.mdx b/apps/docs/mintlify/api-reference/plans/createPlan.mdx index 1e3e2445ec..03386a1515 100644 --- a/apps/docs/mintlify/api-reference/plans/createPlan.mdx +++ b/apps/docs/mintlify/api-reference/plans/createPlan.mdx @@ -550,10 +550,6 @@ await autumn.plans.create({ - - Pin the link to a specific version of the license plan. Omitted, an existing link keeps its pinned version and a new link resolves to the latest. - - @@ -612,7 +608,7 @@ await autumn.plans.create({ - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -626,6 +622,10 @@ await autumn.plans.create({ Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1301,7 +1301,7 @@ await autumn.plans.create({ - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1315,6 +1315,10 @@ await autumn.plans.create({ Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/plans/getPlan.mdx b/apps/docs/mintlify/api-reference/plans/getPlan.mdx index 98c74103ef..9b308af9d0 100644 --- a/apps/docs/mintlify/api-reference/plans/getPlan.mdx +++ b/apps/docs/mintlify/api-reference/plans/getPlan.mdx @@ -598,7 +598,7 @@ const plan = await autumn.plans.get({ - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -612,6 +612,10 @@ const plan = await autumn.plans.get({ Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/plans/listPlans.mdx b/apps/docs/mintlify/api-reference/plans/listPlans.mdx index 096bd5001b..50aaa84be5 100644 --- a/apps/docs/mintlify/api-reference/plans/listPlans.mdx +++ b/apps/docs/mintlify/api-reference/plans/listPlans.mdx @@ -604,7 +604,7 @@ const plans = await autumn.plans.list({ - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -618,6 +618,10 @@ const plans = await autumn.plans.list({ Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api-reference/plans/updatePlan.mdx b/apps/docs/mintlify/api-reference/plans/updatePlan.mdx index a519a235e3..8eb8ea8452 100644 --- a/apps/docs/mintlify/api-reference/plans/updatePlan.mdx +++ b/apps/docs/mintlify/api-reference/plans/updatePlan.mdx @@ -471,10 +471,6 @@ await autumn.plans.update({ - - Pin the link to a specific version of the license plan. Omitted, an existing link keeps its pinned version and a new link resolves to the latest. - - @@ -533,7 +529,7 @@ await autumn.plans.update({ - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -547,6 +543,10 @@ await autumn.plans.update({ Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -697,6 +697,16 @@ await autumn.plans.update({ Variant plan IDs to apply this update to. Empty or omitted means no propagation. + + Parent plan versions that should receive this license-plan update. + + + + + + + + Additive variant updates for this base plan. Missing variants are created when name is provided. @@ -948,7 +958,7 @@ await autumn.plans.update({ - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -962,6 +972,10 @@ await autumn.plans.update({ Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + @@ -1659,7 +1673,7 @@ await autumn.plans.update({ - Optional rate limit to cap how often auto top-ups occur. + Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. The time interval for the purchase limit window. @@ -1673,6 +1687,10 @@ await autumn.plans.update({ Maximum number of auto top-ups allowed within the interval. + + Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + + diff --git a/apps/docs/mintlify/api/openapi.yml b/apps/docs/mintlify/api/openapi.yml index 8f7f67983b..36dd960a98 100644 --- a/apps/docs/mintlify/api/openapi.yml +++ b/apps/docs/mintlify/api/openapi.yml @@ -76,7 +76,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -103,10 +102,16 @@ components: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -339,7 +344,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -1996,7 +2000,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -2023,10 +2026,16 @@ components: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -2197,7 +2206,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -2831,7 +2839,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -2858,10 +2865,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -3185,7 +3198,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -4261,7 +4273,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -5138,7 +5149,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -5165,10 +5175,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -5404,7 +5420,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -6789,13 +6804,6 @@ paths: propertyNames: type: string additionalProperties: {} - version: - type: integer - minimum: 1 - maximum: 9007199254740991 - description: Pin the link to a specific version of the license plan. Omitted, an - existing link keeps its pinned version and a new link - resolves to the latest. required: - license_plan_id description: Plans offered as assignable licenses under this plan. The full set @@ -6858,7 +6866,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -6885,10 +6892,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -7850,7 +7863,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -7877,10 +7889,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed + top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -8054,7 +8073,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -9180,7 +9198,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -9207,10 +9224,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed + top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -9384,7 +9408,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -10489,7 +10512,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -10516,10 +10538,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's + consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -10694,7 +10723,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -11583,13 +11611,6 @@ paths: propertyNames: type: string additionalProperties: {} - version: - type: integer - minimum: 1 - maximum: 9007199254740991 - description: Pin the link to a specific version of the license plan. Omitted, an - existing link keeps its pinned version and a new link - resolves to the latest. required: - license_plan_id description: Plans offered as assignable licenses under this plan. The full set @@ -11655,7 +11676,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -11682,10 +11702,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -11883,6 +11909,23 @@ paths: type: string description: Variant plan IDs to apply this update to. Empty or omitted means no propagation. + update_license_parents: + type: array + items: + type: object + properties: + plan_id: + type: string + minLength: 1 + pattern: ^[a-zA-Z0-9_-]+$ + version: + type: integer + minimum: 1 + maximum: 9007199254740991 + required: + - plan_id + - version + description: Parent plan versions that should receive this license-plan update. variants: type: array items: @@ -12238,7 +12281,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -12265,10 +12307,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed + top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -13223,7 +13272,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -13250,10 +13298,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed + top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -13427,7 +13482,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -15419,7 +15473,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -15446,10 +15499,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -16864,7 +16923,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -16891,10 +16949,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's + consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -17565,7 +17630,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -17592,10 +17656,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's + consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -19284,7 +19355,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -19311,10 +19381,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -22075,7 +22151,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -22102,10 +22177,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -23437,7 +23518,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -23464,10 +23544,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -25921,7 +26007,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -25948,10 +26033,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -28076,7 +28167,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -28853,7 +28943,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -30642,6 +30731,251 @@ paths: range="30d", bin_size="day", ) + /v1/invoices.insert: + post: + operationId: insertInvoices + description: Inserts or updates up to 500 historical invoices without reading or + mutating the billing processor. + tags: + - invoices + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + invoices: + type: array + maxItems: 500 + items: + type: object + properties: + customer_id: + type: string + description: The customer this invoice belongs to. + plan_ids: + type: array + items: + type: string + default: [] + description: Plan IDs represented by this invoice. + stripe_id: + type: string + description: The processor's stable invoice ID. + processor_type: + enum: + - stripe + - revenuecat + type: string + default: stripe + description: The billing processor that owns this invoice. + status: + enum: + - draft + - open + - void + - paid + - uncollectible + type: string + description: The invoice status. + total: + type: number + description: The invoice total in major currency units. + amount_paid: + anyOf: + - type: number + - type: "null" + default: null + description: The amount paid in major currency units. + refunded_amount: + type: number + default: 0 + description: The refunded amount in major currency units. + currency: + type: string + description: The currency code. Defaults to the organization's default currency. + created_at: + type: number + description: The invoice creation timestamp in milliseconds. + hosted_invoice_url: + anyOf: + - type: string + - type: "null" + default: null + description: The hosted invoice URL, when available. + required: + - customer_id + - stripe_id + - status + - total + - created_at + description: Invoices to insert or update, in response order. + required: + - invoices + title: InsertInvoicesParams + examples: + - &a72 + invoices: + - customer_id: cus_123 + plan_ids: + - pro + stripe_id: in_legacy_123 + processor_type: stripe + status: paid + total: 29.99 + amount_paid: 29.99 + refunded_amount: 0 + currency: usd + created_at: 1451606400000 + hosted_invoice_url: https://billing.example.com/invoices/legacy-123 + example: *a72 + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + invoices: + type: array + items: + type: object + properties: + plan_ids: + type: array + items: + type: string + description: Array of plan IDs included in this invoice + stripe_id: + type: string + description: The Stripe invoice ID + processor_type: + enum: + - stripe + - revenuecat + type: string + default: stripe + description: The billing processor that owns this invoice. + status: + type: string + description: The status of the invoice + total: + type: number + description: The total amount of the invoice + currency: + type: string + description: The currency code for the invoice + created_at: + type: number + description: Timestamp when the invoice was created + hosted_invoice_url: + anyOf: + - type: string + - type: "null" + description: URL to the Stripe-hosted invoice page + id: + type: string + description: The Autumn invoice ID. + customer_id: + type: string + description: The customer this invoice belongs to. + amount_paid: + anyOf: + - type: number + - type: "null" + description: The amount paid in major currency units. + refunded_amount: + type: number + description: The refunded amount in major currency units. + required: + - plan_ids + - stripe_id + - status + - total + - currency + - created_at + - id + - customer_id + - amount_paid + - refunded_amount + description: Inserted or updated invoices in request order. + required: + - invoices + examples: + - &a73 + invoices: + - id: inv_2b3c4d5e6f7g8h + customer_id: cus_123 + plan_ids: + - pro + stripe_id: in_legacy_123 + processor_type: stripe + status: paid + total: 29.99 + amount_paid: 29.99 + refunded_amount: 0 + currency: usd + created_at: 1451606400000 + hosted_invoice_url: https://billing.example.com/invoices/legacy-123 + example: *a73 + x-speakeasy-name-override: insert + parameters: + - *a5 + x-codeSamples: + - lang: typescript + label: Typescript (SDK) + source: >- + import { Autumn } from 'autumn-js' + + + const autumn = new Autumn() + + + const result = await autumn.invoices.insert({ + invoices: [ + { + customerId: "cus_123", + planIds: [ + "pro", + ], + stripeId: "in_legacy_123", + status: "paid", + total: 29.99, + amountPaid: 29.99, + currency: "usd", + createdAt: 1451606400000, + hostedInvoiceUrl: "https://billing.example.com/invoices/legacy-123", + }, + ], + }); + - lang: python + label: Python (SDK) + source: >- + from autumn_sdk import Autumn + + + autumn = Autumn(secret_key="am_sk_test...") + + + res = autumn.invoices.insert( + invoices=[ + { + "customer_id": "cus_123", + "plan_ids": [ + "pro", + ], + "stripe_id": "in_legacy_123", + "status": "paid", + "total": 29.99, + "amount_paid": 29.99, + "currency": "usd", + "created_at": 1451606400000, + "hosted_invoice_url": "https://billing.example.com/invoices/legacy-123", + }, + ], + ) /v1/invoices.list: post: operationId: listInvoices @@ -30700,14 +31034,14 @@ paths: before processor tracking count as stripe. title: ListInvoicesParams examples: - - &a72 + - &a74 start_cursor: "" limit: 10 customer_id: cus_123 status: - open - paid - example: *a72 + example: *a74 responses: "200": description: OK @@ -30799,7 +31133,7 @@ paths: - list - next_cursor examples: - - &a73 + - &a75 list: - id: inv_2b3c4d5e6f7g8h customer_id: cus_123 @@ -30816,7 +31150,7 @@ paths: created_at: 1759247877000 hosted_invoice_url: https://invoice.stripe.com/i/acct_123/test_456 next_cursor: null - example: *a73 + example: *a75 x-speakeasy-name-override: list parameters: - *a5 @@ -30895,13 +31229,13 @@ paths: - entities title: AttachLicenseParams examples: - - &a74 + - &a76 customer_id: cus_123 plan_id: seat_plan entities: - entity_id: user_123 name: Ada Lovelace - example: *a74 + example: *a76 responses: "200": description: OK @@ -30980,12 +31314,12 @@ paths: - entity_ids title: ReleaseLicenseParams examples: - - &a75 + - &a77 customer_id: cus_123 license_plan_id: seat_plan entity_ids: - user_123 - example: *a75 + example: *a77 responses: "200": description: OK @@ -31056,10 +31390,10 @@ paths: - customer_id title: ListLicenseAssignmentsParams examples: - - &a76 + - &a78 customer_id: cus_123 active: true - example: *a76 + example: *a78 responses: "200": description: OK @@ -31094,14 +31428,14 @@ paths: required: - list examples: - - &a77 + - &a79 list: - id: lic_asn_123 entity_id: user_123 license_plan_id: seat_plan started_at: 1759247877000 ended_at: null - example: *a77 + example: *a79 x-speakeasy-name-override: listAssignments x-speakeasy-ignore: true parameters: @@ -31127,9 +31461,9 @@ paths: - customer_id title: ListLicensesParams examples: - - &a78 + - &a80 customer_id: cus_123 - example: *a78 + example: *a80 responses: "200": description: OK @@ -31175,7 +31509,7 @@ paths: required: - list examples: - - &a79 + - &a81 list: - license_plan_id: seat_plan parent_plan_id: pro_plan @@ -31184,7 +31518,7 @@ paths: usage: 3 remaining: 7 paid_quantity: 5 - example: *a79 + example: *a81 x-speakeasy-name-override: list x-speakeasy-ignore: true parameters: @@ -31370,12 +31704,12 @@ paths: - entity_id title: CreateEntityParams examples: - - &a80 + - &a82 customer_id: cus_123 entity_id: seat_42 feature_id: seats name: Seat 42 - example: *a80 + example: *a82 responses: "200": description: OK @@ -31906,7 +32240,7 @@ paths: - balances - flags examples: - - &a81 + - &a83 id: seat_42 name: Seat 42 customer_id: cus_123 @@ -31951,7 +32285,7 @@ paths: price: null expires_at: null invoices: [] - example: *a81 + example: *a83 x-speakeasy-name-override: create parameters: - *a5 @@ -32010,11 +32344,11 @@ paths: - entity_id title: GetEntityParams examples: - - &a82 + - &a84 entity_id: seat_42 - customer_id: cus_123 entity_id: seat_42 - example: *a82 + example: *a84 responses: "200": description: OK @@ -32545,7 +32879,7 @@ paths: - balances - flags examples: - - &a83 + - &a85 id: seat_42 name: Seat 42 customer_id: cus_123 @@ -32590,7 +32924,7 @@ paths: price: null expires_at: null invoices: [] - example: *a83 + example: *a85 x-speakeasy-name-override: get parameters: - *a5 @@ -32688,12 +33022,12 @@ paths: paginated call instead of iterating entities.get. title: ListEntitiesParams examples: - - &a84 + - &a86 start_cursor: "" limit: 10 - plans: - id: pro_plan - example: *a84 + example: *a86 responses: "200": description: OK @@ -33245,7 +33579,7 @@ paths: - list - next_cursor examples: - - &a85 + - &a87 list: - id: seat_42 name: Seat 42 @@ -33292,7 +33626,7 @@ paths: expires_at: null invoices: [] next_cursor: null - example: *a85 + example: *a87 x-speakeasy-name-override: list parameters: - *a5 @@ -33481,7 +33815,7 @@ paths: - entity_id title: UpdateEntityParams examples: - - &a86 + - &a88 customer_id: cus_123 entity_id: seat_42 billing_controls: @@ -33490,7 +33824,7 @@ paths: enabled: true limit_type: usage_percentage overage_limit: 120 - example: *a86 + example: *a88 responses: "200": description: OK @@ -34021,7 +34355,7 @@ paths: - balances - flags examples: - - &a87 + - &a89 id: seat_42 name: Seat 42 customer_id: cus_123 @@ -34066,7 +34400,7 @@ paths: price: null expires_at: null invoices: [] - example: *a87 + example: *a89 x-speakeasy-name-override: update parameters: - *a5 @@ -34141,10 +34475,10 @@ paths: - entity_id title: DeleteEntityParams examples: - - &a88 + - &a90 customer_id: cus_123 entity_id: seat_42 - example: *a88 + example: *a90 responses: "200": description: OK @@ -34158,9 +34492,9 @@ paths: required: - success examples: - - &a89 + - &a91 success: true - example: *a89 + example: *a91 x-speakeasy-name-override: delete parameters: - *a5 @@ -34211,10 +34545,10 @@ paths: - program_id title: CreateReferralCodeParams examples: - - &a90 + - &a92 customer_id: cus_123 program_id: prog_123 - example: *a90 + example: *a92 responses: "200": description: OK @@ -34237,11 +34571,11 @@ paths: - customer_id - created_at examples: - - &a91 + - &a93 code: customer_id: created_at: 123 - example: *a91 + example: *a93 x-speakeasy-name-override: createCode parameters: - *a5 @@ -34292,10 +34626,10 @@ paths: - customer_id title: RedeemReferralCodeParams examples: - - &a92 + - &a94 code: REF123 customer_id: cus_456 - example: *a92 + example: *a94 responses: "200": description: OK @@ -34318,11 +34652,11 @@ paths: - customer_id - reward_id examples: - - &a93 + - &a95 id: customer_id: reward_id: - example: *a93 + example: *a95 x-speakeasy-name-override: redeemCode parameters: - *a5 @@ -34363,8 +34697,8 @@ paths: type: object title: RewardsListParams examples: - - &a94 {} - example: *a94 + - &a96 {} + example: *a96 responses: "200": description: OK @@ -34578,10 +34912,10 @@ paths: - coupons - feature_grants examples: - - &a95 + - &a97 coupons: [] feature_grants: [] - example: *a95 + example: *a97 x-speakeasy-name-override: list parameters: - *a5 @@ -34628,10 +34962,10 @@ paths: - customer_id title: RedeemRewardCodeParams examples: - - &a96 + - &a98 code: REWARD10 customer_id: cus_456 - example: *a96 + example: *a98 responses: "200": description: OK @@ -34662,12 +34996,12 @@ paths: - reward_id - entitlements_granted examples: - - &a97 + - &a99 reward_id: reward_789 entitlements_granted: - feature_id: messages balance: 100 - example: *a97 + example: *a99 x-speakeasy-name-override: redeemCode parameters: - *a5 @@ -34729,12 +35063,12 @@ paths: - redirect_url title: LinkRevenueCatParams examples: - - &a98 + - &a100 organization_slug: acme env: test project_name: acme-mobile redirect_url: https://dashboard.useautumn.com/dev?tab=revenuecat - example: *a98 + example: *a100 responses: "200": description: OK @@ -34749,9 +35083,9 @@ paths: - oauth_url title: LinkRevenueCatResponse examples: - - &a99 + - &a101 oauth_url: https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write - example: *a99 + example: *a101 x-speakeasy-name-override: linkRevenueCat parameters: - *a5 @@ -34820,13 +35154,13 @@ paths: - env title: SyncRevenueCatParams examples: - - &a100 + - &a102 organization_slug: acme env: test product_ids: - pro - premium - example: *a100 + example: *a102 responses: "200": description: OK @@ -34892,7 +35226,7 @@ paths: - results title: SyncRevenueCatResponse examples: - - &a101 + - &a103 results: - plan_id: pro status: synced @@ -34903,7 +35237,7 @@ paths: product: created store_push: skipped price: set - example: *a101 + example: *a103 x-speakeasy-name-override: syncRevenueCat parameters: - *a5 @@ -34968,10 +35302,10 @@ paths: - env title: GetRevenueCatKeysParams examples: - - &a102 + - &a104 organization_slug: acme env: test - example: *a102 + example: *a104 responses: "200": description: OK @@ -35034,7 +35368,7 @@ paths: - oauth_access_token title: GetRevenueCatKeysResponse examples: - - &a103 + - &a105 apps: - app_id: app1a2b3c4d app_type: test_store @@ -35045,7 +35379,7 @@ paths: environment: production app_id: app1a2b3c4 oauth_access_token: atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ - example: *a103 + example: *a105 x-speakeasy-name-override: getRevenueCatKeys parameters: - *a5 @@ -35100,9 +35434,9 @@ paths: - customer_id title: MintKeyParams examples: - - &a104 + - &a106 customer_id: cus_123 - example: *a104 + example: *a106 responses: "200": description: OK @@ -35131,12 +35465,12 @@ paths: - access_token - expires_at examples: - - &a105 + - &a107 access_token: am_jwt_eyJhbGciOiJIUzI1NiJ9... refresh_token: am_jwt_eyJhbGciOiJIUzI1NiJ9... expires_at: 1781113864000 refresh_expires_at: 1781196664000 - example: *a105 + example: *a107 x-speakeasy-name-override: mint parameters: - *a5 @@ -35179,8 +35513,8 @@ paths: description: No body. The refresh token is supplied as the Bearer credential; the response is a freshly rotated access + refresh pair. examples: - - &a106 {} - example: *a106 + - &a108 {} + example: *a108 responses: "200": description: OK @@ -35209,12 +35543,12 @@ paths: - access_token - expires_at examples: - - &a107 + - &a109 access_token: am_jwt_eyJhbGciOiJIUzI1NiJ9... refresh_token: am_jwt_eyJhbGciOiJIUzI1NiJ9... expires_at: 1781113864000 refresh_expires_at: 1781196664000 - example: *a107 + example: *a109 x-speakeasy-name-override: refresh parameters: - *a5 @@ -35260,9 +35594,9 @@ paths: - customer_id title: RevokeKeyParams examples: - - &a108 + - &a110 customer_id: cus_123 - example: *a108 + example: *a110 responses: "200": description: OK @@ -35276,9 +35610,9 @@ paths: required: - revoked examples: - - &a109 + - &a111 revoked: true - example: *a109 + example: *a111 x-speakeasy-name-override: revoke parameters: - *a5 diff --git a/apps/docs/mintlify/changelog/changelog.mdx b/apps/docs/mintlify/changelog/changelog.mdx index 9a0113a174..5f7df9e160 100644 --- a/apps/docs/mintlify/changelog/changelog.mdx +++ b/apps/docs/mintlify/changelog/changelog.mdx @@ -4,6 +4,53 @@ mode: "center" description: "Some new things we've shipped at Autumn HQ" --- + + ## License plan customization & propagation + + License plans are now fully customizable per parent. You can override items, prices, and included seat counts on individual license plans, and Autumn rebases those overrides automatically when the base license plan changes — Stripe items and subscription lifecycle stay in sync. [`plans.update`](/api-reference/plans/updatePlan) accepts a `licenses[].customize` block per parent, and responses now include the effective `customize` payload on each license link. Pair with the new `include_license_parents` on [`plans.preview_update`](/api-reference/plans/updatePlan) to see downstream impacts before saving. + + ## Overflow tracking on usage + + [`track`](/api-reference/core/track), [`track_tokens`](/api-reference/core/trackTokens), and [`batchTrack`](/api-reference/core/batchTrack) now accept `overage_behavior: "overflow"`, deducting the full amount past zero and letting balances go negative. Usage-window caps are bypassed, but customer and entity spend limits still clamp — useful when you want to record real usage even when a customer has run out of included balance. + + ## Writable auto top-up purchase counter + + You can now reset or set the runtime purchase counter for [auto top-ups](/documentation/modelling-pricing/auto-top-ups) directly through [`customers.update`](/api-reference/customers/updateCustomer). Pass `billing_controls.auto_topups[].purchase_limit.count` to nudge or reset a customer's purchase count without waiting for the interval to roll over. `count > limit` is rejected with a clean 400, and active windows are preserved. + + ## Automatic recovery for failed customer creation + + Customer create and get-or-create requests that hit rate limits or transient database/Redis errors are now automatically queued and replayed in the background, with globally serialized concurrency and deterministic dedupe. Requests come back with their original semantics and Autumn records whether the replay created or fetched the customer — no more one-off failures during traffic spikes. + + ## License transitions & drop handling + + Plan transitions that drop a license pool (e.g. Team → Pro) now release the affected seat assignments and restore pool capacity in a single atomic step, unblocking migrations that previously errored out. The Attach Product preview warns you when a transition will remove licenses and lists the entities that will be released. Assignments carried across matched successor pools stay intact. + + + + - License catalog updates preview and apply cleanly across parent-plan versioning and in-place edits, preserving existing customer license definitions — [#2288](https://github.com/useautumn/autumn/pull/2288) + - Row-batched license transitions preserve quantities, billing state, and Stripe linkage across immediate and scheduled changes — [#2306](https://github.com/useautumn/autumn/pull/2306) + - Child license plan propagation now previews linked parent-plan impacts and supports explicit propagation targets — [#2295](https://github.com/useautumn/autumn/pull/2295) + - Auto top-up thresholds can now be negative, so you can trigger top-ups once a balance dips below zero — [#2354](https://github.com/useautumn/autumn/pull/2354) + - New Stripe customers imported via `stripe_id` on customer creation now pull in eligible subscriptions and schedules automatically — [#2308](https://github.com/useautumn/autumn/pull/2308) + - Customer dashboard plan filters now resolve in milliseconds instead of tens of seconds on large orgs — [#2362](https://github.com/useautumn/autumn/pull/2362) + + + - Create-schedule previews now show unavoidable immediate charges (initial subscription lines, automatic tax, invoice credits) so previews match the final invoice — [#2329](https://github.com/useautumn/autumn/pull/2329) + - Attach form plan-schedule preview now defaults to **immediate** when unset, matching the UI — [#2345](https://github.com/useautumn/autumn/pull/2345) + - Long checkout URLs in the dashboard truncate from the end with a proper ellipsis instead of clipping both sides — [#2344](https://github.com/useautumn/autumn/pull/2344) + - License assignments are now treated as active only when their pool link has an active parent, so dropped and matched pool transitions behave consistently — [#2353](https://github.com/useautumn/autumn/pull/2353) + - Same-batch customized license plan previews no longer crash on virtual catalog products (empty prices/entitlements) — [#2343](https://github.com/useautumn/autumn/pull/2343) + - Catalog sync tolerates partial product collections, missing entitlements, and missing prices without erroring — [#2317](https://github.com/useautumn/autumn/pull/2317), [#2319](https://github.com/useautumn/autumn/pull/2319), [#2322](https://github.com/useautumn/autumn/pull/2322), [#2324](https://github.com/useautumn/autumn/pull/2324) + - Versioned license schedules and matched plan versions are preserved through Stripe sync — [#2297](https://github.com/useautumn/autumn/pull/2297), [#2284](https://github.com/useautumn/autumn/pull/2284) + - OAuth organization list deduplicates when a user has multiple memberships in the same org — [#2346](https://github.com/useautumn/autumn/pull/2346) + - MCP `getCurrentOrganization` schema now matches the `organization/me` API response, adding `id` and tolerating a missing `user` — [#2140](https://github.com/useautumn/autumn/pull/2140) + - Rate limits on establish routes now return a standard `429` (no `Retry-After`); check and track routes still fail open — [#2384](https://github.com/useautumn/autumn/pull/2384) + - Concurrent migrations no longer duplicate customer plans — [#2282](https://github.com/useautumn/autumn/pull/2282) + - Versioned license pricing renders correctly in the plan dashboard — [#2291](https://github.com/useautumn/autumn/pull/2291) + + + + ## Multi-currency pricing and billing diff --git a/apps/docs/mintlify/docs.json b/apps/docs/mintlify/docs.json index 404bfe0588..bd44ff4f95 100644 --- a/apps/docs/mintlify/docs.json +++ b/apps/docs/mintlify/docs.json @@ -242,6 +242,7 @@ "api-reference/customers/listCustomers", "api-reference/customers/updateCustomer", "api-reference/customers/deleteCustomer", + "api-reference/invoices/insertInvoices", "api-reference/invoices/listInvoices" ] }, diff --git a/apps/docs/mintlify/documentation/mcp.mdx b/apps/docs/mintlify/documentation/mcp.mdx index 98dd449649..b9169325e6 100644 --- a/apps/docs/mintlify/documentation/mcp.mdx +++ b/apps/docs/mintlify/documentation/mcp.mdx @@ -44,16 +44,21 @@ For a long-lived setup, you can also use an Autumn secret key with Bearer auth: ``` - + -[![Install with one click](https://img.shields.io/badge/Install_with_one_click-Cursor-000000?style=flat-square&logoColor=white)](https://cursor.com/en/install-mcp?name=autumn&config=eyJuYW1lIjoiYXV0dW1uIiwidHlwZSI6Imh0dHAiLCJ1cmwiOiJodHRwczovL21jcC51c2VhdXR1bW4uY29tL21jcCJ9) +Run in terminal: -Or add to `~/.cursor/mcp.json`: +```bash +claude mcp add --transport http autumn https://mcp.useautumn.com/mcp +``` + +Or add to your project's `.mcp.json`: ```json { "mcpServers": { "autumn": { + "type": "http", "url": "https://mcp.useautumn.com/mcp" } } @@ -61,21 +66,32 @@ Or add to `~/.cursor/mcp.json`: ``` - + Run in terminal: ```bash -claude mcp add --transport http autumn https://mcp.useautumn.com/mcp +codex mcp add autumn --url https://mcp.useautumn.com/mcp ``` -Or add to your project's `.mcp.json`: +Or add to `~/.codex/config.toml`: + +```toml +[mcp_servers.autumn] +url = "https://mcp.useautumn.com/mcp" +``` + + + + +[![Install with one click](https://img.shields.io/badge/Install_with_one_click-Cursor-000000?style=flat-square&logoColor=white)](https://cursor.com/en/install-mcp?name=autumn&config=eyJuYW1lIjoiYXV0dW1uIiwidHlwZSI6Imh0dHAiLCJ1cmwiOiJodHRwczovL21jcC51c2VhdXR1bW4uY29tL21jcCJ9) + +Or add to `~/.cursor/mcp.json`: ```json { "mcpServers": { "autumn": { - "type": "http", "url": "https://mcp.useautumn.com/mcp" } } @@ -103,22 +119,6 @@ Or add to `~/Library/Application Support/Claude/claude_desktop_config.json`: } ``` - - - -Run in terminal: - -```bash -codex mcp add autumn --url https://mcp.useautumn.com/mcp -``` - -Or add to `~/.codex/config.toml`: - -```toml -[mcp_servers.autumn] -url = "https://mcp.useautumn.com/mcp" -``` - diff --git a/apps/leaf/tests/evals/harness/context/createAutumnApiMock.ts b/apps/leaf/tests/evals/harness/context/createAutumnApiMock.ts index 5c55e0cce4..07590693e5 100644 --- a/apps/leaf/tests/evals/harness/context/createAutumnApiMock.ts +++ b/apps/leaf/tests/evals/harness/context/createAutumnApiMock.ts @@ -205,6 +205,7 @@ const defaultHandlers = { }, getAgentRules: ({ setup }) => setup.agentRules, getCurrentOrganization: () => ({ + id: "org_acme_knowledge_systems", env: "sandbox", name: "Acme Knowledge Systems", slug: "acme-knowledge-systems", diff --git a/firelens.conf b/firelens.conf index 9035154d76..07d6125be4 100644 --- a/firelens.conf +++ b/firelens.conf @@ -1,3 +1,9 @@ +[SERVICE] + # Flightcontrol prepends its own SERVICE block. The production AWS Fluent + # Bit image accepts an additional block and needs this file registered + # before the parser filter can use its built-in `json` parser. + Parsers_File /fluent-bit/etc/parsers.conf + [FILTER] Name modify Match * @@ -27,9 +33,48 @@ Match * Add region ${AWS_REGION} +[FILTER] + Name parser + Match *-firelens-* + Key_Name log + Parser json + Reserve_Data On + Preserve_Key Off + +[FILTER] + Name rewrite_tag + Match *-firelens-* + Rule $level ^(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)$ axiom_express false + Emitter_Name axiom_express_emitter + Emitter_Storage.type memory + Emitter_Mem_Buf_Limit 10M + +[FILTER] + Name modify + Match axiom_express + Remove time + Remove region + Remove container_id + Remove container_name + [OUTPUT] Name http - Match * + Match axiom_express + Host us-east-1.aws.edge.axiom.co + Port 443 + URI /v1/ingest/express + Format json + TLS On + json_date_key _time + json_date_format iso8601 + Header Authorization Bearer ${AXIOM_TOKEN} + Header Content-Type application/json + retry_limit 5 + net.dns.mode TCP + +[OUTPUT] + Name http + Match *-firelens-* Host us-east-1.aws.edge.axiom.co Port 443 URI /v1/ingest/ecs @@ -40,4 +85,4 @@ Header Authorization Bearer ${AXIOM_TOKEN} Header Content-Type application/json retry_limit 5 - net.dns.mode TCP \ No newline at end of file + net.dns.mode TCP diff --git a/knip.json b/knip.json index 0ee79dac32..9b695b5946 100644 --- a/knip.json +++ b/knip.json @@ -39,7 +39,8 @@ "ignore": [ "src/external/tinybird/connections/eventsS3.ts", "src/external/connect/attachSandboxStripeAccount.ts", - "src/cron/resetCron/runClearExpiredResetCron.ts" + "src/cron/resetCron/runClearExpiredResetCron.ts", + "src/external/stripe/common/simulateTestWebhookFailure.ts" ] }, "shared": { @@ -63,7 +64,7 @@ }, "apps/docs": { "entry": ["**/*"], - "project": ["**/*.{ts,js,jsx,css,mdx}"] + "project": ["**/*.{ts,js,jsx}"] }, "apps/sdk-test": { "entry": ["app/**/*.{ts,tsx}", "lib/**/*.{ts,tsx}", "*.ts"], @@ -98,8 +99,7 @@ "project": ["src/**/*.ts", "scripts/**/*.ts"] }, "packages/render": { - "entry": ["tests/**/*.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "project": ["src/**/*.ts"] }, "apps/testbench": { "entry": ["**/*.ts"], diff --git a/others/python-sdk/.speakeasy/code-samples.overlay.yaml b/others/python-sdk/.speakeasy/code-samples.overlay.yaml index b8e46f964d..936b012520 100644 --- a/others/python-sdk/.speakeasy/code-samples.overlay.yaml +++ b/others/python-sdk/.speakeasy/code-samples.overlay.yaml @@ -785,6 +785,38 @@ actions: "plural": "API requests", }) + # Handle response + print(res) + - target: $["paths"]["/v1/invoices.insert"]["post"] + update: + x-codeSamples: + - lang: python + label: Python (SDK) + source: |- + from autumn_sdk import Autumn + + + with Autumn( + x_api_version="2.3.0", + secret_key="", + ) as autumn: + + res = autumn.invoices.insert(invoices=[ + { + "customer_id": "cus_123", + "plan_ids": [ + "pro", + ], + "stripe_id": "in_legacy_123", + "status": "paid", + "total": 29.99, + "amount_paid": 29.99, + "currency": "usd", + "created_at": 1451606400000, + "hosted_invoice_url": "https://billing.example.com/invoices/legacy-123", + }, + ]) + # Handle response print(res) - target: $["paths"]["/v1/invoices.list"]["post"] diff --git a/others/python-sdk/.speakeasy/gen.lock b/others/python-sdk/.speakeasy/gen.lock index 3de29ad1f6..4b78792357 100644 --- a/others/python-sdk/.speakeasy/gen.lock +++ b/others/python-sdk/.speakeasy/gen.lock @@ -1,16 +1,16 @@ lockVersion: 2.0.0 id: 05940b80-1ef8-40f4-9878-822fb2792070 management: - docChecksum: 5f29f84cbded76f22af18749d454e016 + docChecksum: f81a95ffb32713acb147693f47644f88 docVersion: 2.3.0 speakeasyVersion: 1.762.0 generationVersion: 2.882.0 releaseVersion: 0.4.19 configChecksum: b2caf9ea956e3c3c26e7f639fdd4ff2e persistentEdits: - generation_id: e31ec989-5f11-4634-be19-afc9c95533df - pristine_commit_hash: 97fea45a78b2a214fcd96a4a0d8cd3fb93fa6607 - pristine_tree_hash: a749b813d9717da2ef03340d42f2c64f31debfbe + generation_id: 94db51d8-a3ad-4c1e-9b72-d54cf91ac359 + pristine_commit_hash: 09700e90e0a61d65b07abe2fda558414bde70e99 + pristine_tree_hash: c91210be739d76d006d83e040ad7f09e225c47bf features: python: additionalDependencies: 1.0.0 @@ -159,8 +159,8 @@ trackedFiles: pristine_git_object: 4b225a6a18f0583c0a5c0d0f770ef46cd12db337 docs/models/attachautotopup.md: id: 15110f2cdb28 - last_write_checksum: sha1:c9be4205d04d413b0d6d9c339111c7d792b49208 - pristine_git_object: 17993b67ab2db059bb19591f51f3779ffe8d5546 + last_write_checksum: sha1:fb7eb4673e19e5cbd55ea66a4dd85d20b766a3c3 + pristine_git_object: d2ce35bcbdfd111c991acba79d36446b4f39950e docs/models/attachbaseprice.md: id: 9d6b5ae8c450 last_write_checksum: sha1:136d86ac185da442a3e452cefaee5098f9fda771 @@ -363,8 +363,8 @@ trackedFiles: pristine_git_object: 925ea57b3dc4fb417c734ad9eeb3c161d2cfe68a docs/models/attachpurchaselimit.md: id: 1b8ea180aebf - last_write_checksum: sha1:1266d9d7e4acad6420d8656826d1d67c0c6dc3e5 - pristine_git_object: 4976acf64da74f2b82dc3cf776fc9653ff8d1e94 + last_write_checksum: sha1:2f17f6f2782455e0b21c4bfb4ce318f3644efaa1 + pristine_git_object: f05d94fd768b7d182934fc7d2edab05b39678652 docs/models/attachpurchaselimitinterval.md: id: 0a050968da92 last_write_checksum: sha1:829e520883f075fdb853af6f4bd726c891239d95 @@ -663,8 +663,8 @@ trackedFiles: pristine_git_object: fe865ad29596f3a5c72a5ac55589086ae8b20064 docs/models/billingupdateautotopup.md: id: 5b3d651d9246 - last_write_checksum: sha1:6ffec6bca0b127ab2bce7af83dbc7f567a8750c6 - pristine_git_object: 13c752e910ef8397fbad049040098047ff76f8c9 + last_write_checksum: sha1:fa9f8653d5c58ce0e66ba3a44c2c24d662c03c5e + pristine_git_object: 8d434095c4b1dd27dee529a676544df57591f831 docs/models/billingupdatebaseprice.md: id: c632c15479cf last_write_checksum: sha1:e76ef3c07fb0a53af8c4ae9981b07e379191fbd2 @@ -839,8 +839,8 @@ trackedFiles: pristine_git_object: 2fe315798c7425eea4b333ea5a3be11455a5e45d docs/models/billingupdatepurchaselimit.md: id: e8ab03617105 - last_write_checksum: sha1:914aa71b58dfd8ce1df2d8b6eb5ee9832ad1b862 - pristine_git_object: a1be7a56fba22870807e81d2a7a3f6e77cf58dcb + last_write_checksum: sha1:a53558e42bd7a34922b052fef8b4082037780b00 + pristine_git_object: 14a94398d03db8e932e2ecebd316fc61e2a93bc7 docs/models/billingupdatepurchaselimitinterval.md: id: ac34c00d2a69 last_write_checksum: sha1:b95040a12884533e450cc65f29b7708e769fe597 @@ -1507,8 +1507,8 @@ trackedFiles: pristine_git_object: 18912b7d8ea75eb28d8c7b26b260cbe0b7b3e5ac docs/models/createplanautotopuprequest.md: id: 5b8c1f2c1e5e - last_write_checksum: sha1:a42a98ee8e83e237d26c7acdc9aafdc541ef517c - pristine_git_object: 7f2952912916622cc4c2c10254b69609fadf8e25 + last_write_checksum: sha1:3d5eec21a14c5a8c1953a626a1f6cdf22aa1cfb7 + pristine_git_object: ddcc38d3427e9c4386502784fb7eb934f9be1aef docs/models/createplanautotopupresponse.md: id: 95f17e3ab250 last_write_checksum: sha1:bb0b532ed8193b821de82784146e057cf4c11088 @@ -1715,8 +1715,8 @@ trackedFiles: pristine_git_object: 447af59f78baa29a13e33aeb45f58d7ab338bbcc docs/models/createplanlicense.md: id: bb1159dc173b - last_write_checksum: sha1:07ba830c9fd6ecbe366e29b20b9c06965d426e76 - pristine_git_object: af69a297101e5e81a1bd20fa546b45c8cd9fc630 + last_write_checksum: sha1:ce7c37a8fdb4d391a8308e3c552e1baa935a1232 + pristine_git_object: a03eb3be825b560365401cfa9cda38aef5865b90 docs/models/createplanlicenseadditemadditionalcurrency.md: id: fe0de8969329 last_write_checksum: sha1:d5b242f4d1be2ae9d0c2ca45b422dd7e842615d2 @@ -1891,8 +1891,8 @@ trackedFiles: pristine_git_object: d0186ff49ec916df60ab6b1b6a767fd0894cde84 docs/models/createplanpurchaselimitrequest.md: id: 071c769e81ec - last_write_checksum: sha1:dda3f921fbef9455bd18b263adf4eeda75338eb9 - pristine_git_object: 8d777218a7e21f7ac54c4ad9d9d3cb0ce9300cf7 + last_write_checksum: sha1:eab27cc21dd4a2f02c0648620ff4bbf7cc7c3d88 + pristine_git_object: da8bf517a90de32afe03540dcea9b65ca00caa9d docs/models/createplanpurchaselimitresponse.md: id: 6c1246186aa6 last_write_checksum: sha1:7b869cd5b84e9c84e31b6cbefe81eb35c2adb51c @@ -1975,8 +1975,8 @@ trackedFiles: pristine_git_object: 6a253774e18e4e86bae8ce4a58269d0db49df4e5 docs/models/createplanvariantdetailsautotopup.md: id: c984520c75ca - last_write_checksum: sha1:05612b5707e4599528357b6823bec8bfc71b70c1 - pristine_git_object: bc5281cf86028f7dc0f00cc6adb5b399d39c53c2 + last_write_checksum: sha1:6f76843c667c739300a7990807de3f33a2bd0779 + pristine_git_object: 698ce80b6cf5b8677695a773ffa5a02be9a5540e docs/models/createplanvariantdetailsbillingcontrols.md: id: ae5beb6e1689 last_write_checksum: sha1:57ee7a8210dbc93cdd5a9ef98a7ea7b2d0953c68 @@ -2023,8 +2023,8 @@ trackedFiles: pristine_git_object: 917de0238e95eaefae558f949050655ab15cd876 docs/models/createplanvariantdetailspurchaselimit.md: id: c7aef1c5ade3 - last_write_checksum: sha1:4fb7d33541cf232bbb13d9c4b2f36b3100c69df0 - pristine_git_object: ea15306d0dbbf0c074d9669add6a1df6e449cd63 + last_write_checksum: sha1:ac4e46903f3de9792d294648171d822b763175fd + pristine_git_object: f276f58047d94fae2d8910eff6fed2e9ad113d58 docs/models/createplanvariantdetailspurchaselimitinterval.md: id: 087c419f9a82 last_write_checksum: sha1:eee7a5787e37fe90c7867ca7aafa5e771ce7ac8e @@ -2159,8 +2159,8 @@ trackedFiles: pristine_git_object: 115eb2fd82f0358d49f960ddca8e8f651fc8b33f docs/models/createscheduleautotopup2.md: id: 06f15100a5d2 - last_write_checksum: sha1:a9ff84d7e6ae6cd7789dee8c3f02e52085f1eec8 - pristine_git_object: 6dde81cc563717b7e615c442528defbb29e42c72 + last_write_checksum: sha1:22d0b7ee3f4501b061f4b1138dcfa0b3b3226cf3 + pristine_git_object: b8d533146470d265d8e521c62db91bc94f6a55ea docs/models/createschedulebaseprice2.md: id: 085a5cb811d8 last_write_checksum: sha1:def7fa9dda15e1bc7f0275f1cff1a83a898da0a6 @@ -2299,8 +2299,8 @@ trackedFiles: pristine_git_object: 1e05140406e5b892611a0e99e19efe47887bdf90 docs/models/createschedulepurchaselimit2.md: id: ba44df61a018 - last_write_checksum: sha1:c7932a218fd8c1142f9eddd3ed60531209639ea8 - pristine_git_object: 2684491f0d5971a475cd1fa511a4c32ecd40485a + last_write_checksum: sha1:c79e2274b3f06f779873e11f4396ffec2ec27d20 + pristine_git_object: cce0a613efdf929fb21353d00b53858f2d88e91d docs/models/createschedulepurchaselimitinterval2.md: id: 710eceb9eac1 last_write_checksum: sha1:6462a9c6fed4b22781eae8a1433fded5378ca1b5 @@ -2371,8 +2371,8 @@ trackedFiles: pristine_git_object: 322f87ce6357ae1918cd174de701fca6fc93bd77 docs/models/customerdataautotopup.md: id: d37b23cdcc27 - last_write_checksum: sha1:599e96167eead0ea87a6b3965819019ea4437f01 - pristine_git_object: cf6e86760fcb5e8d9a375b62760578fc71f70d20 + last_write_checksum: sha1:433e9c8b113eb943d47903cdded0b1954e598c8d + pristine_git_object: a5d99fd5517ed078f9dbfd8d2a61c0b52e866a53 docs/models/customerdatabillingcontrols.md: id: 9a9f4fd47b8a last_write_checksum: sha1:5a4fbe5d38c490cca6cd4c41fd78725cacac86c6 @@ -2395,8 +2395,8 @@ trackedFiles: pristine_git_object: 4e59bfa9db3b3f1293ce0cda920140ea981b9e09 docs/models/customerdatapurchaselimit.md: id: c415017a8c4d - last_write_checksum: sha1:5fbe7355e01e4dabb7743d839402502e3d60276d - pristine_git_object: f08f426d51c40b4642f63f55e8005de29868f9c9 + last_write_checksum: sha1:754e8cccbf67011908c56579d574d104ea7e9f42 + pristine_git_object: 4c66c61268b24daa0f61591d7bf7d1b07a3af5b0 docs/models/customerdatapurchaselimitinterval.md: id: 2f2f6406b75e last_write_checksum: sha1:08a5e556685e524455fe6628ca03903bf26c4b50 @@ -3095,8 +3095,8 @@ trackedFiles: pristine_git_object: f5f28a920ccbcc244822b0994dcf89419eef72f7 docs/models/getorcreatecustomerautotopup.md: id: 2beff692c107 - last_write_checksum: sha1:980d9d480f667e5f75ee005128a4023488a139bd - pristine_git_object: 71910553ee8796a2dfbe5c63d62515ab63b0c395 + last_write_checksum: sha1:b34a5b6887423474a02f17f6464386491cff0896 + pristine_git_object: 6574c394f189b419b8f4be217084634cfdc8e05c docs/models/getorcreatecustomerbillingcontrols.md: id: c863934c5751 last_write_checksum: sha1:2e5c116229b1061befedcba9938cb00ebf3b4d0c @@ -3131,8 +3131,8 @@ trackedFiles: pristine_git_object: 63394b3bc1cc7c1670c5212a6b3d813c0b3b8ef1 docs/models/getorcreatecustomerpurchaselimit.md: id: "425587326514" - last_write_checksum: sha1:d1d028dde2dcade9702ce1a70966f045baf49c74 - pristine_git_object: 324803fdec501d444e3e929e13aa29cbff75b39d + last_write_checksum: sha1:337d4a049f4b9c0eed1fce47d7485ffdf571cfc5 + pristine_git_object: 6750d5d3b725807dfad3ae61a657190173af537d docs/models/getorcreatecustomerpurchaselimitinterval.md: id: b8251af4ba20 last_write_checksum: sha1:82af2be0dc953f3f71c37eea8be1f823f195cd76 @@ -3411,8 +3411,8 @@ trackedFiles: pristine_git_object: 91a6df1aba96e704ab4757be8aefafc3e228cd98 docs/models/getplanvariantdetailsautotopup.md: id: 00dbb93a61e3 - last_write_checksum: sha1:4ba39bc774344474c74537948402b7de6539a68b - pristine_git_object: 5ae0bb51165473bfeaa0f5cf2b5e899118129846 + last_write_checksum: sha1:5fbc4ed00eb8049c9c6181517bde4921e8786014 + pristine_git_object: fb229fe4e21894aaeca708018ee3cd0bda336e49 docs/models/getplanvariantdetailsbillingcontrols.md: id: 1547325cf915 last_write_checksum: sha1:e5ecdcceead9de4e368c8f3afe4838b6bf365f0c @@ -3447,8 +3447,8 @@ trackedFiles: pristine_git_object: da6f6059e2dcd671ee16e5082bb6de10286880e8 docs/models/getplanvariantdetailspurchaselimit.md: id: 755524a499a6 - last_write_checksum: sha1:a31a17a34b17d42e7d0646f1756389c6f48cbac0 - pristine_git_object: 772a730e464769cdb6238dbb68f414b818c541b4 + last_write_checksum: sha1:efd9486cc077f47bc8562bb6396636d21601d8ea + pristine_git_object: 6c3293e65c6ae1d11fc1ce69308f16fd968f7745 docs/models/getplanvariantdetailspurchaselimitinterval.md: id: 8b09af747476 last_write_checksum: sha1:f0b33809106d249845b2b0957629bc18299808da @@ -3577,6 +3577,34 @@ trackedFiles: id: a866162d4224 last_write_checksum: sha1:6e8b4bdfa39d20c43de5bfe3f4d40c2e4e63405f pristine_git_object: d113b4a74c800629028857f59b3367aefb73dfee + docs/models/insertinvoicesglobals.md: + id: 896556440fa4 + last_write_checksum: sha1:a45db96ededd519bf05008ace80057af442dcf76 + pristine_git_object: dce2abacca661b46a44cba5d5fbfecf665509fdb + docs/models/insertinvoicesinvoiceresponse.md: + id: 156dca2629c3 + last_write_checksum: sha1:80666717648be6832a10a433afef87d2fb6700fb + pristine_git_object: 707e172ee422e33df0f31c90ce515b0ff27d61ef + docs/models/insertinvoicesparams.md: + id: 893d7e76c910 + last_write_checksum: sha1:0f0c18790c9947972015db134844b9ee5000ce43 + pristine_git_object: 8acd64990dc16cecfa19a69356740c1b104858a2 + docs/models/insertinvoicesprocessortyperequest.md: + id: 55f3b6a2fff0 + last_write_checksum: sha1:af22d90204a44b4f15d93a72acbfe962439dbb5f + pristine_git_object: fb73649ba669cd179b6dfb637e6c5ba388c90ca8 + docs/models/insertinvoicesprocessortyperesponse.md: + id: 99db41b8f870 + last_write_checksum: sha1:51062e66a9f00fa37c7fdb6116860a88c86eb485 + pristine_git_object: a89798aad594fafd47bc390d7c65bc8c0570eaa7 + docs/models/insertinvoicesresponse.md: + id: 43fe0576c3fe + last_write_checksum: sha1:2de57a074f8d997296bf7d421e011ae44d8f4451 + pristine_git_object: 78a1bce92cb3204819bbe76fb2539217b20a266b + docs/models/insertinvoicesstatus.md: + id: 6a6fea5e7446 + last_write_checksum: sha1:ef37186e1ae62dbc283fd401c356768977aa7422 + pristine_git_object: b83f5d07a153815d5148db9f6665fee2dc492e9d docs/models/intent.md: id: a4a6fa8818f7 last_write_checksum: sha1:9079fedc03e6b5c3469c752774a972f27bdab857 @@ -3597,6 +3625,10 @@ trackedFiles: id: 18e2034f11ad last_write_checksum: sha1:be8a764edc2849da9e554de10fa04005cbe9eed9 pristine_git_object: b58012425affb550214b7d1799072123933f2f3c + docs/models/invoicerequestbody.md: + id: 44fc14c7ffe3 + last_write_checksum: sha1:973d9ad0919ebc4bb4fb2195d7830df61291ca40 + pristine_git_object: 018dfbb87b09c163c4b4c0ebc8276e45740d31ab docs/models/item.md: id: 40dd7473ab87 last_write_checksum: sha1:4e2e7c45d325aa5eedab794f0d5ffce51c2d7e0d @@ -4043,8 +4075,12 @@ trackedFiles: pristine_git_object: 301869758bccff1b9ef10091d15fdf4569adf99c docs/models/listinvoicesparams.md: id: 9ec138cd60df - last_write_checksum: sha1:750a629ea2bc230dfd96abe000aab2aefb8a9b9d - pristine_git_object: ce7dffb6635949f3fce490e4b64a2c68d7f44e44 + last_write_checksum: sha1:6ca44b0c5e6fe1a65cba3cb9e81dda1174f0bd08 + pristine_git_object: a9a3c5b90e2aac581bc9fec44cce0f82c9687a17 + docs/models/listinvoicesprocessortyperequest.md: + id: 20842fa2267e + last_write_checksum: sha1:95e2bc40f4a6c716cf23146a35f806ca7f2409a4 + pristine_git_object: 5b5ba72368cc43864eda156016c20eb83aaa0f43 docs/models/listinvoicesresponse.md: id: 9eafac82c643 last_write_checksum: sha1:245400ba75d9506554386d1ea7c55a6407f6951f @@ -4311,8 +4347,8 @@ trackedFiles: pristine_git_object: b08aad360f30f4657b9ccb89ec95b7aa745acfba docs/models/listplansvariantdetailsautotopup.md: id: 20c33dc6e5ca - last_write_checksum: sha1:be638999b8c60289754d96b0e0a6f1bc0349bcdf - pristine_git_object: 94983a79195b5126e42d75377423cdf762ce66a0 + last_write_checksum: sha1:bcf4b841e6399c02d3082460342f32aeb324448e + pristine_git_object: 339362f7c26ece090297b0b458a6d458ad189c43 docs/models/listplansvariantdetailsbillingcontrols.md: id: f5477e249645 last_write_checksum: sha1:fc56f310f7de1696d8780147cbf0e0c67306e46c @@ -4347,8 +4383,8 @@ trackedFiles: pristine_git_object: 2ddce0ac83d7fea8823fe7e619c0b6b81544830a docs/models/listplansvariantdetailspurchaselimit.md: id: b631a937a083 - last_write_checksum: sha1:8e5c403a8dafae0d2987c78783cb452234d91d25 - pristine_git_object: d75739f14a5ec9173d1b76e3bc502093ccd321a0 + last_write_checksum: sha1:e759fb7f6d414cd3e81cc1fe16fd3f31de75cbf0 + pristine_git_object: 717cf18603cdbe6797541bf5696cc9092cf140c8 docs/models/listplansvariantdetailspurchaselimitinterval.md: id: 55bcccf59241 last_write_checksum: sha1:c2a4993f94ca705901d705eb9987aa490eeec120 @@ -4855,8 +4891,8 @@ trackedFiles: pristine_git_object: aef5b6c1bc30b6efa4eca01abcdc24c6e5cbd2dc docs/models/planvariantdetailsautotopup.md: id: 4ec4f15fa7c1 - last_write_checksum: sha1:73355bfb2509464f6cc00e1534c15fed48585086 - pristine_git_object: d23d7b5a6e28230d24a9884646cc040ac0c67c99 + last_write_checksum: sha1:b9e4b49e925a3ed3e19545298fe4c6c0b2a6dfc8 + pristine_git_object: a40cd869b27123408574fcfdb4a3661ec4f26b13 docs/models/planvariantdetailsbillingcontrols.md: id: bded401054c6 last_write_checksum: sha1:3fae1efce480f700b96060becaa2a93396c07a2d @@ -4883,8 +4919,8 @@ trackedFiles: pristine_git_object: ced9f5af8e6be7355149cdf978cfab022958e1e9 docs/models/planvariantdetailspurchaselimit.md: id: a9bb69a6fe6a - last_write_checksum: sha1:b75ba6a6dde3d65d901ae816977f69ff6721d25f - pristine_git_object: 91ee2745ab4bf483facb6c29644f9abe67b26e79 + last_write_checksum: sha1:757ca9f7e750e346c024e2a8a88aaf905bddaf17 + pristine_git_object: 9f8c602b2da21ce474f72acca12f90ed3436b0cf docs/models/planvariantdetailspurchaselimitinterval.md: id: 933acdf1458a last_write_checksum: sha1:fa74328ea572dde6724182eea1688f640f878b19 @@ -5015,8 +5051,8 @@ trackedFiles: pristine_git_object: 9d29579d60ad03509a0c3a9322beea8adc82f55a docs/models/previewattachautotopup.md: id: 65bde9ecb54d - last_write_checksum: sha1:2627dba7fd4fd1f3524ffaf9eb7f6987f7c72979 - pristine_git_object: 9b49268d7642e604708a44d66adac9acf1e63720 + last_write_checksum: sha1:daa89e8e7d748894416d06955635d6530cc9d3aa + pristine_git_object: 3d6896260bdb77f524c28642456435ff0b9ba957 docs/models/previewattachbaseprice.md: id: 7c2843459273 last_write_checksum: sha1:97d8c02b3aebcc3ec12ec4f7b8d0bff60042f056 @@ -5247,8 +5283,8 @@ trackedFiles: pristine_git_object: 1cc089658a20a07a4f3d3a449f7373cf36037a74 docs/models/previewattachpurchaselimit.md: id: eaf96494549f - last_write_checksum: sha1:a5c47167f681c88878b8ee1f1b0d1fd7fc987a6d - pristine_git_object: 7adfbaead71c98d79abd0e74f96b50c312f0faf3 + last_write_checksum: sha1:0fe5b8c585d412b5685b06bdb5d7345c65c4b2f0 + pristine_git_object: d1e494fc2aa9e80d35c8203901f6b9c1636f05d1 docs/models/previewattachpurchaselimitinterval.md: id: c151362a7be0 last_write_checksum: sha1:74cfeae33a66c2ca173f550e9d745090fd8a88be @@ -5771,8 +5807,8 @@ trackedFiles: pristine_git_object: db64b2ae127d3a36dd6ae6b7f58c3b22cfa3f1b6 docs/models/previewupdateautotopup.md: id: cf7e5007afc5 - last_write_checksum: sha1:452112047f7e01f0e2d0d89aec19f7e982199b81 - pristine_git_object: 5c4ec762f6994de1fe6138c449c5cf8df9d89f64 + last_write_checksum: sha1:0ec22052037019df1dee8ece7d9a9db636366201 + pristine_git_object: f707b6720ed744efad086cf45149fa73d86a9bbd docs/models/previewupdatebaseprice.md: id: 7eea7a31d277 last_write_checksum: sha1:3dabf1a24414b7e207019e974f6097c5bf45210f @@ -5991,8 +6027,8 @@ trackedFiles: pristine_git_object: f09eb4baade271cd90673805ea85230e5fbc9c49 docs/models/previewupdatepurchaselimit.md: id: 66a59b49f6c1 - last_write_checksum: sha1:e34de5a2ef1bad2bcaf5e444da3ed4c2368e4921 - pristine_git_object: ac502e3dab64b3e1496fcbe72178036490872393 + last_write_checksum: sha1:4e4d1df2b51e23aba0dc3a1de6e0ca777e7db2d2 + pristine_git_object: 1d794c8aad8191cb4f566f0daf194e38333dc015 docs/models/previewupdatepurchaselimitinterval.md: id: 13adbd8dcb91 last_write_checksum: sha1:0ba67c0c7c983475b49d49ebbecf8bc8364e11c8 @@ -6149,10 +6185,6 @@ trackedFiles: id: 446cf8386114 last_write_checksum: sha1:c917a956f1af010a60b2e7d43bad09cf54e44274 pristine_git_object: c42de1ea48789d1cd09dcce226e3673f29b2a747 - docs/models/processortyperequest.md: - id: 2a044477e341 - last_write_checksum: sha1:9caa03e4659e2c364b2cd7d65425d1a79a52d22c - pristine_git_object: 57f091070525d10d5f0ac09b3363a02ae749f579 docs/models/productdisplay1.md: id: b5bdefcde7af last_write_checksum: sha1:4cbda567685f39d23b4a83d5a9bc20c4bf2fa21b @@ -6371,8 +6403,8 @@ trackedFiles: pristine_git_object: 53fc7933fee507da4b362252d0a4cfc577d17536 docs/models/setuppaymentautotopup.md: id: f8823dfe9bb8 - last_write_checksum: sha1:c1a7583624a45319cfba427d7b9c3044b3a81e6b - pristine_git_object: c97fc39d68c0a45d49dd6f338b967b4f2f0dcd67 + last_write_checksum: sha1:89f949b4687280c1bf2f67b8ae1484db4f763021 + pristine_git_object: 7f5424f6e9260c805d907ad5609e70027b4b0e5a docs/models/setuppaymentbaseprice.md: id: a123b4da0cd9 last_write_checksum: sha1:8e848145fe4e5658f781d2b7bf8f335f45df7949 @@ -6543,8 +6575,8 @@ trackedFiles: pristine_git_object: b79cc25122d2f3e75b5504ac2ab52c1f1ff527cf docs/models/setuppaymentpurchaselimit.md: id: c48cf8871c7d - last_write_checksum: sha1:e79cc44eb67e609bbb1dfa2c2dabdf0226d82b4c - pristine_git_object: 088f8a4fab9aa814b52db52fd746052eae83267f + last_write_checksum: sha1:d797346c0c9e37932287987039d8268420e187ae + pristine_git_object: 6227468d8c0a52ee2c7b36d649a5a19cb4951c62 docs/models/setuppaymentpurchaselimitinterval.md: id: 7865886ab45a last_write_checksum: sha1:16186c61933c15f0b1de65c62a101c21cf556563 @@ -6863,8 +6895,8 @@ trackedFiles: pristine_git_object: e2df03126ca9aa71027bcd8f9e8def06b4a6e612 docs/models/updatecustomerautotopuprequest.md: id: 39142e7d45b0 - last_write_checksum: sha1:604a27462c5217342eba9f56ee3f926148841a66 - pristine_git_object: d576ca1d37b88d129169b9331e2ad509b395ed3f + last_write_checksum: sha1:5e4598d420a7dc0433b9c0418ae13ed7047819c6 + pristine_git_object: b68764fa0895acb91cd9c833c2ab93b28064068b docs/models/updatecustomerautotopupresponse.md: id: c57d6aa94fbc last_write_checksum: sha1:4f153c173360fc6fcc8cbdb03e09e1da68924a09 @@ -6983,8 +7015,8 @@ trackedFiles: pristine_git_object: 8ecdcfc2e320fe3c2bfd6e923b01ed5a1788417c docs/models/updatecustomerpurchaselimitrequest.md: id: e0b5bae8abfe - last_write_checksum: sha1:10242d53f27a2a7d4a79da4896f4613149cee33d - pristine_git_object: 3be65ea39fee47ad7839606ec40c91548819e4b5 + last_write_checksum: sha1:922d69ff13d7fb7858d48df2f078c608958e612d + pristine_git_object: 6d530408dce896b2b15eb814ae175b20da8317d6 docs/models/updatecustomerpurchaselimitresponse1.md: id: accb82caac88 last_write_checksum: sha1:3c2bf9fd114c8d1b206813c20568f607b8b57791 @@ -7301,6 +7333,10 @@ trackedFiles: id: 0ba0a862dcca last_write_checksum: sha1:ba4000eac9f25f6713b827f89da999285b58cc65 pristine_git_object: 18e672cee28b83060d88007bb59a207cdc9629e0 + docs/models/updatelicenseparent.md: + id: fb6397bab933 + last_write_checksum: sha1:88ce39f7b5fc556228d474287d78ee953da1e1fe + pristine_git_object: 3140fad320aedbe89065b1e8ae6bd07ab41a8679 docs/models/updateplanadditionalcurrencyrequestbody.md: id: 26d669eae669 last_write_checksum: sha1:7e0bdebafca3486b1a1876d9a99ca570fbe96b20 @@ -7315,8 +7351,8 @@ trackedFiles: pristine_git_object: ad36f93503838af401489616dfce1916dccb08f4 docs/models/updateplanautotopuprequest.md: id: 728d617ae5eb - last_write_checksum: sha1:f5cb8ee53cebc6b16401faa7635b7d63ecd80234 - pristine_git_object: a5e748d059d65a84c79d16736cb2a87e8550e72d + last_write_checksum: sha1:334ded4ea3a144b5d6bce2aa1d97e020ca42d5d8 + pristine_git_object: ef5c3b4edb5abce2b0e3678feb7c3f725502fc76 docs/models/updateplanautotopupresponse.md: id: 4fe573b999aa last_write_checksum: sha1:3aaeed8eed9cb2b1571f7eda2af99a1c8cb0f4ca @@ -7531,8 +7567,8 @@ trackedFiles: pristine_git_object: 432c4fb72f3384999ee204702b893a5487ec9a74 docs/models/updateplanlicense.md: id: 6a25f2678c05 - last_write_checksum: sha1:1035b10b12d5f8eec4dad29ecef762227d278860 - pristine_git_object: fe62f77263f8cadb0c1c921cf4fcbb943f730b30 + last_write_checksum: sha1:255422f5f98945a05d446b36b252bfffcdbb4255 + pristine_git_object: 759ac8e540eea5123caf2aca065121f180756f9f docs/models/updateplanlicenseadditemadditionalcurrency.md: id: 57a5ce673d19 last_write_checksum: sha1:2f9c3819331ab4c4bb4509c0c7487cb66736af30 @@ -7647,8 +7683,8 @@ trackedFiles: pristine_git_object: b849611da8ce48f7a76c6a8822e84a9d7e9b0db4 docs/models/updateplanparams.md: id: ca9b432d1066 - last_write_checksum: sha1:bf230f0833d1034438be47c70f80c1afaf5a6c12 - pristine_git_object: 11908e7a23fe21a46f9ed14c2752e2f75a01306f + last_write_checksum: sha1:d20ee9530005cc04254f35d80a96d84aa4d0356d + pristine_git_object: b2b182541d46f04595f41e1b888f9eb7b3a86015 docs/models/updateplanplanitemfilterresponse.md: id: 092ca30ea3f6 last_write_checksum: sha1:3fc7dff4908aff333620e77c741255c23219731e @@ -7703,8 +7739,8 @@ trackedFiles: pristine_git_object: cc9b07ca8c2ac1efd152d8ba6766a21940b73875 docs/models/updateplanpurchaselimitrequest.md: id: 058893a720b3 - last_write_checksum: sha1:c128844e4e0059c0bf75632cdb2d05226f5237c7 - pristine_git_object: bec49bd4b4b021c30caefa5fd8095b5d7158c562 + last_write_checksum: sha1:951867d0765f3cf713909bfe41652719fa6eb806 + pristine_git_object: 54e83e8c18dbab2a886f3c5f039b0849027a7b6c docs/models/updateplanpurchaselimitresponse.md: id: 025600dee2ce last_write_checksum: sha1:be4ceea4313f227268f0000a24fb22b25dedb447 @@ -7787,8 +7823,8 @@ trackedFiles: pristine_git_object: b39d03627b6cbe2c35595f1b681f23fd30c1ee71 docs/models/updateplanvariantdetailsautotopup.md: id: f66104927470 - last_write_checksum: sha1:89c00bf1d4e4685bff349435fd2a109ad54380c8 - pristine_git_object: 8f895b641858560dde6e168cd092548df6381040 + last_write_checksum: sha1:b357ad2d82564b4d0af588c39fd7d8a3ef239123 + pristine_git_object: e084c5697d128d10dbbdcaeea7d4ae00853100bd docs/models/updateplanvariantdetailsbillingcontrols.md: id: 99b1c1fbf647 last_write_checksum: sha1:f6ce128f0ff39d91b4e4473289e240b64e1aa241 @@ -7835,8 +7871,8 @@ trackedFiles: pristine_git_object: eb32c3ad5079eb3852d69a1954f153e1ee745f3a docs/models/updateplanvariantdetailspurchaselimit.md: id: 22b1b43959b1 - last_write_checksum: sha1:8d21a9d5159482abcf488b7c9f4cde1a142c9edf - pristine_git_object: 5188545d44c37b4ca6870578057fc3e15c82774b + last_write_checksum: sha1:dee286b8214d0208e152cb3696ddbcd555b53de6 + pristine_git_object: 45cd6001907ceef6d84b236d59733bae77612a22 docs/models/updateplanvariantdetailspurchaselimitinterval.md: id: 98af6395686b last_write_checksum: sha1:d13a16bcddca61b0849e29ec81d53d1cfbb6d7cc @@ -7939,8 +7975,8 @@ trackedFiles: pristine_git_object: c885af85d97cba500333c381cb34b08840d5d7c6 docs/models/variantautotopup.md: id: 88d5f637822a - last_write_checksum: sha1:eb0debe413221244d34df3d3a87a65b50123b480 - pristine_git_object: d0ef4aeb7abae13d434b35ac7feec3fc00d44a19 + last_write_checksum: sha1:dfe6aafebb85059dac5f3498374bb60c91996c7d + pristine_git_object: 0902bb8314c81bf9bed43ddce63f2e7c21139796 docs/models/variantbaseprice.md: id: f24c0ca3e17f last_write_checksum: sha1:15243e2252c69a2c0fca2b6c156e831586de495f @@ -8039,8 +8075,8 @@ trackedFiles: pristine_git_object: a1a72d6862a3facb180ccdfa7deab63822f0845d docs/models/variantpurchaselimit.md: id: 44a108b4dc23 - last_write_checksum: sha1:2ad6c8d5115a9c8ed9524e895dff6f7c4fe0bd12 - pristine_git_object: edd0f8c25af520bfcf5ac4759d1561961892fef7 + last_write_checksum: sha1:c8eda2c641b5ef7d6e1647a6a8ecf7f59aeddb00 + pristine_git_object: 1d9cfc8683fcee03fb56163196813e6c1b9da44f docs/models/variantpurchaselimitinterval.md: id: 7092bab14de4 last_write_checksum: sha1:58ebc09fdb19add7c99836b73b687e686d07cd9c @@ -8131,8 +8167,8 @@ trackedFiles: pristine_git_object: 337f58429b8e20186d1780093f18e4bf96c6133e docs/sdks/invoices/README.md: id: a3f76a06eded - last_write_checksum: sha1:0aa1b7c06e2fe4395fe1197735e44a7972d3438e - pristine_git_object: a37d17dd6cbbfa20fdaef4b596c92afde0600b51 + last_write_checksum: sha1:6bc7ac5b0095cca09a281822ec02cd9065dfef39 + pristine_git_object: 3bce2dae27e55339ee5923f0415e419e8eef5683 docs/sdks/keys/README.md: id: 9a567cd0daf2 last_write_checksum: sha1:feda26eeb2d8049e97f4f40bde5479981a7b5e39 @@ -8143,8 +8179,8 @@ trackedFiles: pristine_git_object: 91a73d64b8b66030bf05508df0353b634584b6bc docs/sdks/plans/README.md: id: 2d8c741fff57 - last_write_checksum: sha1:f6e172676e159ba2c3c00f6d68333ee45ad975d2 - pristine_git_object: 224bd77e9ca1e30436ec68d61fd3b6b4cc2333a2 + last_write_checksum: sha1:ef0674fc6cc00f06162dcce0880968f0c82a8742 + pristine_git_object: 06b99615d5cb056e7ae0d9f1a2ca4e768206d581 docs/sdks/platform/README.md: id: b66219e9cd4d last_write_checksum: sha1:e18d4e05e801ff5b52414fc51e98e10f33cfbb04 @@ -8247,8 +8283,8 @@ trackedFiles: pristine_git_object: 89560b566073785535643e694c112bedbd3db13d src/autumn_sdk/invoices.py: id: 25223c7e4c52 - last_write_checksum: sha1:0c832df7a053da10c5ff73de6ba21f097ce0c4d6 - pristine_git_object: 4fb0916d1fabed241ee2f69fa741a9fd1df2c105 + last_write_checksum: sha1:356e23073f95fb37849eb9c4474b482b2625356e + pristine_git_object: cc3243d2f49ce8e6645a9fe2d523a5308c267fb0 src/autumn_sdk/keys.py: id: 078ab2a6d01a last_write_checksum: sha1:809ab7ca51e2ebfead59fc15c2712747f74f38b0 @@ -8259,8 +8295,8 @@ trackedFiles: pristine_git_object: ab7599187acf4fbd6a77ff0288d4ed12ea3d55ba src/autumn_sdk/models/__init__.py: id: bcf3802243ff - last_write_checksum: sha1:9c03cd23656a890c1f5257a35aeb9e6a8d74a462 - pristine_git_object: 399430441a1741142bb239d2e6dc484af379534a + last_write_checksum: sha1:5e93dc1424597a9bfb1845f7010525c7d19f462a + pristine_git_object: 8cdb542a7efa7e90eed06e0d3f6867cd7b25564a src/autumn_sdk/models/aggregateeventsop.py: id: 01321099f2a5 last_write_checksum: sha1:acf521584ece8e3e217b02ec157d7ed43b783f21 @@ -8271,8 +8307,8 @@ trackedFiles: pristine_git_object: 46847dd0edc83a5f2c950765d7f4e3e3190ba2ed src/autumn_sdk/models/attachop.py: id: ebb59e06476c - last_write_checksum: sha1:40481bf405ad0b6cf8fcb37d21ee2d277484e80a - pristine_git_object: 53c94f0f20197c041873fca4ab861bc7bccc0b39 + last_write_checksum: sha1:20ee6cf084b6f90792a00776c1047f3eaa5c1913 + pristine_git_object: e5980dcd883d2c58b32ce2ee475dd17a9b463a6d src/autumn_sdk/models/balance.py: id: a6354d7c4b97 last_write_checksum: sha1:b5ce91bd9a42de2332529dd64e83aa1154fd3d0e @@ -8283,8 +8319,8 @@ trackedFiles: pristine_git_object: 104214260539e267dba373b7f140bea00456c518 src/autumn_sdk/models/billingupdateop.py: id: a2f17c75cfd3 - last_write_checksum: sha1:ff552cd8515f0b1fc35d592e368fc2b6b6bbd809 - pristine_git_object: 146135ee44a89e2c3499bb8fe885834972d97962 + last_write_checksum: sha1:2881d25ca2192e9cd7cdc5a8d2b57a2d63b0b044 + pristine_git_object: 155564345faa8f16c69c30d67f04a430c12210cf src/autumn_sdk/models/checkop.py: id: 31c2f84723c6 last_write_checksum: sha1:901e4f0ac685d3b1d551792d32fe913d0be2901e @@ -8303,24 +8339,24 @@ trackedFiles: pristine_git_object: 496a076933b3e87f7a049a22543d43e13b34ae56 src/autumn_sdk/models/createplanop.py: id: 077e6c7db2ad - last_write_checksum: sha1:8a283473602a5310940681839b74b235f522f33b - pristine_git_object: 60a530501b933de009d348df432570b6d5bdd395 + last_write_checksum: sha1:094db93bae2948a9a67a75f1aa023809da38d9b5 + pristine_git_object: 328e6ee0a9733df5419801d49fa1232c666055b0 src/autumn_sdk/models/createreferralcodeop.py: id: 2f5f7b136c39 last_write_checksum: sha1:9ef6a7e87ffd4cb0fb91f3592abb3b1ef51cb415 pristine_git_object: fea4d1bb3875848e66502705e4cb818233ec3f5a src/autumn_sdk/models/createscheduleop.py: id: afb0cf1cf7f2 - last_write_checksum: sha1:1a50c784e8c499e673702184d13c9648ce774abf - pristine_git_object: 0d261c93c0c67f74f3e9e21d69af2f3ea089aff1 + last_write_checksum: sha1:b04db551595734ec117319fa4156c68c059d8a83 + pristine_git_object: 23bdb2f0183a1a374bf37b71c35be731e7b10bc4 src/autumn_sdk/models/customer.py: id: 8ed0174f7272 last_write_checksum: sha1:30638da8b64e5ea23787fd1e50b8a051a2aa2031 pristine_git_object: dc08d3cbe2bc9b9f99612301d8534c0663511bbd src/autumn_sdk/models/customerdata.py: id: 9d88118f2123 - last_write_checksum: sha1:89e08ccf05d450a52f071d482c87c1bb5d2238a9 - pristine_git_object: 880d031cf79b1de8074472ce67c51e13b821c070 + last_write_checksum: sha1:c09c1cdb9013fb0c13f7ad6a5bc1c9412d01c4f5 + pristine_git_object: 5e26531d3899ee4b78eb5a53ab19cd7d24c1b9d6 src/autumn_sdk/models/customerexpand.py: id: 9d337d480baa last_write_checksum: sha1:ea31eb539b3854d917a7d8fe5685f730b8577a1f @@ -8363,12 +8399,12 @@ trackedFiles: pristine_git_object: e5644620facf26204c3a512525886fed0054c447 src/autumn_sdk/models/getorcreatecustomerop.py: id: acfac0d7be14 - last_write_checksum: sha1:7e1ada1ef743598d6a348031e191bada46a47d97 - pristine_git_object: 6c0acae13f607be68b72415882915f02a97df16e + last_write_checksum: sha1:e0791efc77a21f33d29522a5dc93be2c6f959256 + pristine_git_object: 7a843b58495be022dc9efcda60f142b40ae060d4 src/autumn_sdk/models/getplanop.py: id: 590fb77ac88d - last_write_checksum: sha1:f8a3baac7d3dd49ce7c573ca1533af1599f4d544 - pristine_git_object: aa3169c8b82b012608caf6d61f68df5e9f61f29e + last_write_checksum: sha1:d5900ea3cf7c13f25bbe7125c42fe53dbd4b82c2 + pristine_git_object: 5f3b4a9b114b33c33c190ed0cf063778a235819d src/autumn_sdk/models/getrevenuecatkeysop.py: id: 015155862a71 last_write_checksum: sha1:6b3f88a48b721093562ec59db3f6dae2540fa139 @@ -8377,6 +8413,10 @@ trackedFiles: id: bd28bf9634d8 last_write_checksum: sha1:7bed106390df2e41d8f36efb6ef306843e19021b pristine_git_object: f6891cf0ca781a2636a1edb361abaa72a6ce8705 + src/autumn_sdk/models/insertinvoicesop.py: + id: f40dc5a707d2 + last_write_checksum: sha1:753dbdc5c1ffbac7f80191b8310a7dacdbbdd920 + pristine_git_object: c7ebbe6eff442c215aa718603113b99caed61bec src/autumn_sdk/models/internal/__init__.py: id: 2906fe7f2cde last_write_checksum: sha1:1905b58b74ecc52346d8f5c24ded2b6d6e1dad4a @@ -8407,12 +8447,12 @@ trackedFiles: pristine_git_object: b5077df0f24113f9e7c5fae25e5fefd923b7ec85 src/autumn_sdk/models/listinvoicesop.py: id: efd56888952f - last_write_checksum: sha1:40d9b902025d9542a94c4ec0ac6abdf1a891ab63 - pristine_git_object: 66bfca14d664bd33afbeeb57ffe3f279e5a8ecba + last_write_checksum: sha1:500ea4117a1e76d3feb69b1df9a97886717a2b3d + pristine_git_object: 82723c21210d8b89229a1683d8230c9372afce43 src/autumn_sdk/models/listplansop.py: id: fdf892c403f4 - last_write_checksum: sha1:13331888063ee99fa3f12d3d668f17b287c73178 - pristine_git_object: 0f2b19fd6d340499a5a0e103d32d167f598db7e4 + last_write_checksum: sha1:4de1c87862550f9a036ecae6ee774848dd76dcf8 + pristine_git_object: f58d48fbff6f78eb85fc2567890f1a1d698e1a7a src/autumn_sdk/models/listrewardsop.py: id: de90eebc7e95 last_write_checksum: sha1:270f334d9955ca547175b4c936ba0ac416002f20 @@ -8435,12 +8475,12 @@ trackedFiles: pristine_git_object: 79ac016f241d82916ccc1aab7c7e3a7e3fb9aef1 src/autumn_sdk/models/plan.py: id: f85c4e07540d - last_write_checksum: sha1:f509e8d77e5c067ca337600c31047dceb28ec601 - pristine_git_object: 883f3c3c7fc99d0a34f6c133c83277b5ac8a7f6b + last_write_checksum: sha1:52e039025172080ab9259118740982381ba818e0 + pristine_git_object: 0ffd9b3918b2a6b47237579d608cd80a998fdb68 src/autumn_sdk/models/previewattachop.py: id: 2b361be4bfa8 - last_write_checksum: sha1:3f78e99797fcd65170eb9e72542ba8e8f9a89898 - pristine_git_object: 8dd937dd8a86c2f3896fbf87b8fc1d81081c314f + last_write_checksum: sha1:3287d894a35f49d8dbc0ddf7dbdd49538696d6c0 + pristine_git_object: 6a6f1ad73793a408b692e7ae86884fe94bc0b987 src/autumn_sdk/models/previewmultiattachop.py: id: 963ffcd646a4 last_write_checksum: sha1:13eae6db5aea44988a2cc6c1ee688fc53cc2dac0 @@ -8451,8 +8491,8 @@ trackedFiles: pristine_git_object: 85872f73a69aba31c8cbcd5da7f9f59b254dde49 src/autumn_sdk/models/previewupdateop.py: id: 081d5f08508d - last_write_checksum: sha1:ba82a17b54dbd204f6bb2f77931dd7a6b19dbe8d - pristine_git_object: 25e80afc47fdc9f1cc8d8effc07f900a1c83ce39 + last_write_checksum: sha1:ed38b9a691984414d264e40732ea31c045fc301a + pristine_git_object: 0054391992a70d0cdd8ecf81d169b8691b75d36a src/autumn_sdk/models/redeemreferralcodeop.py: id: 0abd7bfae718 last_write_checksum: sha1:b1a584450f1f79e796dd755a9305dc30a07ed601 @@ -8479,8 +8519,8 @@ trackedFiles: pristine_git_object: aa686dd6f85ae1e27450392fcfe02527adfe8e61 src/autumn_sdk/models/setuppaymentop.py: id: 603339ee67e3 - last_write_checksum: sha1:edf22e8537f17f0309d4b685648cba918d83f724 - pristine_git_object: 8762148494eb5d0b501a6310dcce0080e9d90970 + last_write_checksum: sha1:155e6225117748c2dfb538a802a58d8e151f8e0d + pristine_git_object: 999805c95feb931dbab0adfac1c91807eb4d182f src/autumn_sdk/models/syncrevenuecatop.py: id: faddfbfd1214 last_write_checksum: sha1:a2b90222b09b4ca95bc78d3573f5b17d4d50208b @@ -8499,8 +8539,8 @@ trackedFiles: pristine_git_object: 7709cff4a867ccbb2022899e6a6751542d8f3fae src/autumn_sdk/models/updatecustomerop.py: id: 28b9d5b59bae - last_write_checksum: sha1:fb4e390d525214b69261761b657ac6c1134b75d2 - pristine_git_object: 2f9f14dd4cc1794f86c9b17fbaae3dc038691456 + last_write_checksum: sha1:b938fff71d98513e74d46e78275ac9a13e99d7f8 + pristine_git_object: 85750af20855b999c70ace333181143ad833498c src/autumn_sdk/models/updateentityop.py: id: a49305af1e2e last_write_checksum: sha1:de10e55412a7a7b7dfb98c5e040353407eff2210 @@ -8511,12 +8551,12 @@ trackedFiles: pristine_git_object: daa78399b45fcaeda3fbe5843677a6900c520d4d src/autumn_sdk/models/updateplanop.py: id: 753ddf45ca40 - last_write_checksum: sha1:68ec8316b2b7381728e3e6a8183ae4480aa1097b - pristine_git_object: 7bfbabf54d964c70f55360e6417a43eba1d1fb98 + last_write_checksum: sha1:495149262bcac87ff868712a66b2ed92c711b8d6 + pristine_git_object: 23ff21a47df57a32885e4679db18dc1f15e310e3 src/autumn_sdk/plans.py: id: cf1ebabb687c - last_write_checksum: sha1:2cf01e1e6a7a24cc5c9934ed26278278b13f1df9 - pristine_git_object: bdc12bb6677bd7d3704d8e7e7b04d5b0b1180593 + last_write_checksum: sha1:ae4517b5e4c271794210ce03397eed27fa3a1646 + pristine_git_object: 3baac7b5e75376ef1dc5af7440470884c8681889 src/autumn_sdk/platform.py: id: aee79240c441 last_write_checksum: sha1:2e80cdba550e5487a2ac16c063e24caf3f2b265f @@ -9386,4 +9426,14 @@ examples: responses: "200": application/json: {"list": [{"license_plan_id": "seat_plan", "parent_plan_id": "pro_plan", "license_plan_name": "Seat", "granted": 10, "usage": 3, "remaining": 7, "paid_quantity": 5}]} + insertInvoices: + speakeasy-default-insert-invoices: + parameters: + header: + x-api-version: "2.3.0" + requestBody: + application/json: {"invoices": [{"customer_id": "cus_123", "plan_ids": ["pro"], "stripe_id": "in_legacy_123", "processor_type": "stripe", "status": "paid", "total": 29.99, "amount_paid": 29.99, "refunded_amount": 0, "currency": "usd", "created_at": 1451606400000, "hosted_invoice_url": "https://billing.example.com/invoices/legacy-123"}]} + responses: + "200": + application/json: {"invoices": [{"plan_ids": ["pro"], "stripe_id": "in_legacy_123", "processor_type": "stripe", "status": "paid", "total": 29.99, "currency": "usd", "created_at": 1451606400000, "hosted_invoice_url": "https://billing.example.com/invoices/legacy-123", "id": "inv_2b3c4d5e6f7g8h", "customer_id": "cus_123", "amount_paid": 29.99, "refunded_amount": 0}]} examplesVersion: 1.0.2 diff --git a/others/python-sdk/README.md b/others/python-sdk/README.md index 4c94e5b21a..4857650aa5 100644 --- a/others/python-sdk/README.md +++ b/others/python-sdk/README.md @@ -300,6 +300,7 @@ Use this to permanently remove a feature. Note: features that are used in produc ### [Invoices](docs/sdks/invoices/README.md) +* [insert](docs/sdks/invoices/README.md#insert) - Inserts or updates up to 500 historical invoices without reading or mutating the billing processor. * [list](docs/sdks/invoices/README.md#list) - Lists invoices with cursor pagination and optional filters (customer, entity, status, processor). Pass `start_cursor: ""` (or omit) for the first page; use `next_cursor` from a prior response for subsequent pages. ### [Keys](docs/sdks/keys/README.md) diff --git a/others/python-sdk/src/autumn_sdk/invoices.py b/others/python-sdk/src/autumn_sdk/invoices.py index 4fb0916d1f..cc3243d2f4 100644 --- a/others/python-sdk/src/autumn_sdk/invoices.py +++ b/others/python-sdk/src/autumn_sdk/invoices.py @@ -5,10 +5,198 @@ from autumn_sdk._hooks import HookContext from autumn_sdk.types import OptionalNullable, UNSET from autumn_sdk.utils.unmarshal_json_response import unmarshal_json_response -from typing import List, Mapping, Optional +from typing import List, Mapping, Optional, Union class Invoices(BaseSDK): + def insert( + self, + *, + invoices: Union[ + List[models.InvoiceRequestBody], List[models.InvoiceRequestBodyTypedDict] + ], + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.InsertInvoicesResponse: + r"""Inserts or updates up to 500 historical invoices without reading or mutating the billing processor. + + :param invoices: Invoices to insert or update, in response order. + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(base_url, url_variables) + + request = models.InsertInvoicesParams( + invoices=utils.get_pydantic_model( + invoices, List[models.InvoiceRequestBody] + ), + ) + + req = self._build_request( + method="POST", + path="/v1/invoices.insert", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + _globals=models.InsertInvoicesGlobals( + x_api_version=self.sdk_configuration.globals.x_api_version, + ), + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.InsertInvoicesParams + ), + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = self.do_request( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="insertInvoices", + oauth2_scopes=None, + security_source=self.sdk_configuration.security, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.InsertInvoicesResponse, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + + raise errors.AutumnDefaultError("Unexpected response received", http_res) + + async def insert_async( + self, + *, + invoices: Union[ + List[models.InvoiceRequestBody], List[models.InvoiceRequestBodyTypedDict] + ], + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.InsertInvoicesResponse: + r"""Inserts or updates up to 500 historical invoices without reading or mutating the billing processor. + + :param invoices: Invoices to insert or update, in response order. + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(base_url, url_variables) + + request = models.InsertInvoicesParams( + invoices=utils.get_pydantic_model( + invoices, List[models.InvoiceRequestBody] + ), + ) + + req = self._build_request_async( + method="POST", + path="/v1/invoices.insert", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + _globals=models.InsertInvoicesGlobals( + x_api_version=self.sdk_configuration.globals.x_api_version, + ), + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.InsertInvoicesParams + ), + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = await self.do_request_async( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="insertInvoices", + oauth2_scopes=None, + security_source=self.sdk_configuration.security, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.InsertInvoicesResponse, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + + raise errors.AutumnDefaultError("Unexpected response received", http_res) + def list( self, *, @@ -17,7 +205,7 @@ def list( customer_id: Optional[str] = None, entity_id: Optional[str] = None, status: Optional[List[models.ListInvoicesStatus]] = None, - processor_types: Optional[List[models.ProcessorTypeRequest]] = None, + processor_types: Optional[List[models.ListInvoicesProcessorTypeRequest]] = None, retries: OptionalNullable[utils.RetryConfig] = UNSET, server_url: Optional[str] = None, timeout_ms: Optional[int] = None, @@ -122,7 +310,7 @@ async def list_async( customer_id: Optional[str] = None, entity_id: Optional[str] = None, status: Optional[List[models.ListInvoicesStatus]] = None, - processor_types: Optional[List[models.ProcessorTypeRequest]] = None, + processor_types: Optional[List[models.ListInvoicesProcessorTypeRequest]] = None, retries: OptionalNullable[utils.RetryConfig] = UNSET, server_url: Optional[str] = None, timeout_ms: Optional[int] = None, diff --git a/others/python-sdk/src/autumn_sdk/models/__init__.py b/others/python-sdk/src/autumn_sdk/models/__init__.py index 399430441a..8cdb542a7e 100644 --- a/others/python-sdk/src/autumn_sdk/models/__init__.py +++ b/others/python-sdk/src/autumn_sdk/models/__init__.py @@ -1539,6 +1539,21 @@ Link, LinkTypedDict, ) + from .insertinvoicesop import ( + InsertInvoicesGlobals, + InsertInvoicesGlobalsTypedDict, + InsertInvoicesInvoiceResponse, + InsertInvoicesInvoiceResponseTypedDict, + InsertInvoicesParams, + InsertInvoicesParamsTypedDict, + InsertInvoicesProcessorTypeRequest, + InsertInvoicesProcessorTypeResponse, + InsertInvoicesResponse, + InsertInvoicesResponseTypedDict, + InsertInvoicesStatus, + InvoiceRequestBody, + InvoiceRequestBodyTypedDict, + ) from .linkrevenuecatop import ( LinkRevenueCatEnv, LinkRevenueCatGlobals, @@ -1730,10 +1745,10 @@ ListInvoicesListTypedDict, ListInvoicesParams, ListInvoicesParamsTypedDict, + ListInvoicesProcessorTypeRequest, ListInvoicesResponse, ListInvoicesResponseTypedDict, ListInvoicesStatus, - ProcessorTypeRequest, ) from .listplansop import ( ListPlansAddItemAdditionalCurrency, @@ -3125,6 +3140,8 @@ Migration, MigrationTypedDict, PriceVariantInterval, + UpdateLicenseParent, + UpdateLicenseParentTypedDict, UpdatePlanAdditionalCurrencyRequestBody, UpdatePlanAdditionalCurrencyRequestBodyTypedDict, UpdatePlanAdditionalCurrencyResponse, @@ -4879,10 +4896,23 @@ "IncludedUsage1TypedDict", "IncludedUsage2", "IncludedUsage2TypedDict", + "InsertInvoicesGlobals", + "InsertInvoicesGlobalsTypedDict", + "InsertInvoicesInvoiceResponse", + "InsertInvoicesInvoiceResponseTypedDict", + "InsertInvoicesParams", + "InsertInvoicesParamsTypedDict", + "InsertInvoicesProcessorTypeRequest", + "InsertInvoicesProcessorTypeResponse", + "InsertInvoicesResponse", + "InsertInvoicesResponseTypedDict", + "InsertInvoicesStatus", "Intent", "IntervalVariantRemoveItemEnum1", "IntervalVariantRemoveItemEnum2", "Invoice", + "InvoiceRequestBody", + "InvoiceRequestBodyTypedDict", "InvoiceTypedDict", "Item", "ItemAdditionalCurrency", @@ -5071,6 +5101,7 @@ "ListInvoicesListTypedDict", "ListInvoicesParams", "ListInvoicesParamsTypedDict", + "ListInvoicesProcessorTypeRequest", "ListInvoicesResponse", "ListInvoicesResponseTypedDict", "ListInvoicesStatus", @@ -5939,7 +5970,6 @@ "PreviewUpdateUsageLineItemTypedDict", "PriceVariantInterval", "ProcessorType", - "ProcessorTypeRequest", "Processors", "ProcessorsTypedDict", "ProductDisplay1", @@ -6421,6 +6451,8 @@ "UpdateFeatureResponseTypedDict", "UpdateFeatureTypeRequestBody", "UpdateFeatureTypeResponse", + "UpdateLicenseParent", + "UpdateLicenseParentTypedDict", "UpdatePlanAdditionalCurrencyRequestBody", "UpdatePlanAdditionalCurrencyRequestBodyTypedDict", "UpdatePlanAdditionalCurrencyResponse", @@ -8222,6 +8254,19 @@ "ImportType": ".importop", "Link": ".importop", "LinkTypedDict": ".importop", + "InsertInvoicesGlobals": ".insertinvoicesop", + "InsertInvoicesGlobalsTypedDict": ".insertinvoicesop", + "InsertInvoicesInvoiceResponse": ".insertinvoicesop", + "InsertInvoicesInvoiceResponseTypedDict": ".insertinvoicesop", + "InsertInvoicesParams": ".insertinvoicesop", + "InsertInvoicesParamsTypedDict": ".insertinvoicesop", + "InsertInvoicesProcessorTypeRequest": ".insertinvoicesop", + "InsertInvoicesProcessorTypeResponse": ".insertinvoicesop", + "InsertInvoicesResponse": ".insertinvoicesop", + "InsertInvoicesResponseTypedDict": ".insertinvoicesop", + "InsertInvoicesStatus": ".insertinvoicesop", + "InvoiceRequestBody": ".insertinvoicesop", + "InvoiceRequestBodyTypedDict": ".insertinvoicesop", "LinkRevenueCatEnv": ".linkrevenuecatop", "LinkRevenueCatGlobals": ".linkrevenuecatop", "LinkRevenueCatGlobalsTypedDict": ".linkrevenuecatop", @@ -8402,10 +8447,10 @@ "ListInvoicesListTypedDict": ".listinvoicesop", "ListInvoicesParams": ".listinvoicesop", "ListInvoicesParamsTypedDict": ".listinvoicesop", + "ListInvoicesProcessorTypeRequest": ".listinvoicesop", "ListInvoicesResponse": ".listinvoicesop", "ListInvoicesResponseTypedDict": ".listinvoicesop", "ListInvoicesStatus": ".listinvoicesop", - "ProcessorTypeRequest": ".listinvoicesop", "ListPlansAddItemAdditionalCurrency": ".listplansop", "ListPlansAddItemAdditionalCurrencyTypedDict": ".listplansop", "ListPlansAddItemBillingMethod": ".listplansop", @@ -9748,6 +9793,8 @@ "Migration": ".updateplanop", "MigrationTypedDict": ".updateplanop", "PriceVariantInterval": ".updateplanop", + "UpdateLicenseParent": ".updateplanop", + "UpdateLicenseParentTypedDict": ".updateplanop", "UpdatePlanAdditionalCurrencyRequestBody": ".updateplanop", "UpdatePlanAdditionalCurrencyRequestBodyTypedDict": ".updateplanop", "UpdatePlanAdditionalCurrencyResponse": ".updateplanop", diff --git a/others/python-sdk/src/autumn_sdk/models/attachop.py b/others/python-sdk/src/autumn_sdk/models/attachop.py index 53c94f0f20..e5980dcd88 100644 --- a/others/python-sdk/src/autumn_sdk/models/attachop.py +++ b/others/python-sdk/src/autumn_sdk/models/attachop.py @@ -1127,7 +1127,7 @@ def serialize_model(self, handler): class AttachPurchaseLimitTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: AttachPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1135,10 +1135,12 @@ class AttachPurchaseLimitTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class AttachPurchaseLimit(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: AttachPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1149,9 +1151,12 @@ class AttachPurchaseLimit(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -1176,7 +1181,7 @@ class AttachAutoTopupTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[AttachPurchaseLimitTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -1195,7 +1200,7 @@ class AttachAutoTopup(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[AttachPurchaseLimit] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" diff --git a/others/python-sdk/src/autumn_sdk/models/billingupdateop.py b/others/python-sdk/src/autumn_sdk/models/billingupdateop.py index 146135ee44..155564345f 100644 --- a/others/python-sdk/src/autumn_sdk/models/billingupdateop.py +++ b/others/python-sdk/src/autumn_sdk/models/billingupdateop.py @@ -1141,7 +1141,7 @@ def serialize_model(self, handler): class BillingUpdatePurchaseLimitTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: BillingUpdatePurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1149,10 +1149,12 @@ class BillingUpdatePurchaseLimitTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class BillingUpdatePurchaseLimit(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: BillingUpdatePurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1163,9 +1165,12 @@ class BillingUpdatePurchaseLimit(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -1190,7 +1195,7 @@ class BillingUpdateAutoTopupTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[BillingUpdatePurchaseLimitTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -1209,7 +1214,7 @@ class BillingUpdateAutoTopup(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[BillingUpdatePurchaseLimit] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" diff --git a/others/python-sdk/src/autumn_sdk/models/createplanop.py b/others/python-sdk/src/autumn_sdk/models/createplanop.py index 60a530501b..328e6ee0a9 100644 --- a/others/python-sdk/src/autumn_sdk/models/createplanop.py +++ b/others/python-sdk/src/autumn_sdk/models/createplanop.py @@ -1158,8 +1158,6 @@ class CreatePlanLicenseTypedDict(TypedDict): prepaid_only: NotRequired[bool] customize: NotRequired[Nullable[CreatePlanLicenseCustomizeTypedDict]] metadata: NotRequired[Dict[str, Any]] - version: NotRequired[int] - r"""Pin the link to a specific version of the license plan. Omitted, an existing link keeps its pinned version and a new link resolves to the latest.""" class CreatePlanLicense(BaseModel): @@ -1173,14 +1171,9 @@ class CreatePlanLicense(BaseModel): metadata: Optional[Dict[str, Any]] = None - version: Optional[int] = None - r"""Pin the link to a specific version of the license plan. Omitted, an existing link keeps its pinned version and a new link resolves to the latest.""" - @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set( - ["included", "prepaid_only", "customize", "metadata", "version"] - ) + optional_fields = set(["included", "prepaid_only", "customize", "metadata"]) nullable_fields = set(["customize"]) serialized = handler(self) m = {} @@ -1304,7 +1297,7 @@ def serialize_model(self, handler): class CreatePlanPurchaseLimitRequestTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: CreatePlanPurchaseLimitIntervalRequestBody r"""The time interval for the purchase limit window.""" @@ -1312,10 +1305,12 @@ class CreatePlanPurchaseLimitRequestTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class CreatePlanPurchaseLimitRequest(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: CreatePlanPurchaseLimitIntervalRequestBody r"""The time interval for the purchase limit window.""" @@ -1326,9 +1321,12 @@ class CreatePlanPurchaseLimitRequest(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -1353,7 +1351,7 @@ class CreatePlanAutoTopupRequestTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[CreatePlanPurchaseLimitRequestTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -1372,7 +1370,7 @@ class CreatePlanAutoTopupRequest(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[CreatePlanPurchaseLimitRequest] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -3222,7 +3220,7 @@ def serialize_model(self, handler): class CreatePlanVariantDetailsPurchaseLimitTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: CreatePlanVariantDetailsPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -3230,10 +3228,12 @@ class CreatePlanVariantDetailsPurchaseLimitTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class CreatePlanVariantDetailsPurchaseLimit(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: CreatePlanVariantDetailsPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -3244,9 +3244,12 @@ class CreatePlanVariantDetailsPurchaseLimit(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -3271,7 +3274,7 @@ class CreatePlanVariantDetailsAutoTopupTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[CreatePlanVariantDetailsPurchaseLimitTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -3290,7 +3293,7 @@ class CreatePlanVariantDetailsAutoTopup(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[CreatePlanVariantDetailsPurchaseLimit] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" diff --git a/others/python-sdk/src/autumn_sdk/models/createscheduleop.py b/others/python-sdk/src/autumn_sdk/models/createscheduleop.py index 0d261c93c0..23bdb2f018 100644 --- a/others/python-sdk/src/autumn_sdk/models/createscheduleop.py +++ b/others/python-sdk/src/autumn_sdk/models/createscheduleop.py @@ -1148,7 +1148,7 @@ def serialize_model(self, handler): class CreateSchedulePurchaseLimit2TypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: CreateSchedulePurchaseLimitInterval2 r"""The time interval for the purchase limit window.""" @@ -1156,10 +1156,12 @@ class CreateSchedulePurchaseLimit2TypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class CreateSchedulePurchaseLimit2(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: CreateSchedulePurchaseLimitInterval2 r"""The time interval for the purchase limit window.""" @@ -1170,9 +1172,12 @@ class CreateSchedulePurchaseLimit2(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -1197,7 +1202,7 @@ class CreateScheduleAutoTopup2TypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[CreateSchedulePurchaseLimit2TypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -1216,7 +1221,7 @@ class CreateScheduleAutoTopup2(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[CreateSchedulePurchaseLimit2] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" diff --git a/others/python-sdk/src/autumn_sdk/models/customerdata.py b/others/python-sdk/src/autumn_sdk/models/customerdata.py index 880d031cf7..5e26531d38 100644 --- a/others/python-sdk/src/autumn_sdk/models/customerdata.py +++ b/others/python-sdk/src/autumn_sdk/models/customerdata.py @@ -24,7 +24,7 @@ class CustomerDataPurchaseLimitTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: CustomerDataPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -32,10 +32,12 @@ class CustomerDataPurchaseLimitTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class CustomerDataPurchaseLimit(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: CustomerDataPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -46,9 +48,12 @@ class CustomerDataPurchaseLimit(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -73,7 +78,7 @@ class CustomerDataAutoTopupTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[CustomerDataPurchaseLimitTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -92,7 +97,7 @@ class CustomerDataAutoTopup(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[CustomerDataPurchaseLimit] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" diff --git a/others/python-sdk/src/autumn_sdk/models/getorcreatecustomerop.py b/others/python-sdk/src/autumn_sdk/models/getorcreatecustomerop.py index 6c0acae13f..7a843b5849 100644 --- a/others/python-sdk/src/autumn_sdk/models/getorcreatecustomerop.py +++ b/others/python-sdk/src/autumn_sdk/models/getorcreatecustomerop.py @@ -54,7 +54,7 @@ def serialize_model(self, handler): class GetOrCreateCustomerPurchaseLimitTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: GetOrCreateCustomerPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -62,10 +62,12 @@ class GetOrCreateCustomerPurchaseLimitTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class GetOrCreateCustomerPurchaseLimit(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: GetOrCreateCustomerPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -76,9 +78,12 @@ class GetOrCreateCustomerPurchaseLimit(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -103,7 +108,7 @@ class GetOrCreateCustomerAutoTopupTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[GetOrCreateCustomerPurchaseLimitTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -122,7 +127,7 @@ class GetOrCreateCustomerAutoTopup(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[GetOrCreateCustomerPurchaseLimit] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" diff --git a/others/python-sdk/src/autumn_sdk/models/getplanop.py b/others/python-sdk/src/autumn_sdk/models/getplanop.py index aa3169c8b8..5f3b4a9b11 100644 --- a/others/python-sdk/src/autumn_sdk/models/getplanop.py +++ b/others/python-sdk/src/autumn_sdk/models/getplanop.py @@ -1484,7 +1484,7 @@ def serialize_model(self, handler): class GetPlanVariantDetailsPurchaseLimitTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: GetPlanVariantDetailsPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1492,10 +1492,12 @@ class GetPlanVariantDetailsPurchaseLimitTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class GetPlanVariantDetailsPurchaseLimit(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: GetPlanVariantDetailsPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1506,9 +1508,12 @@ class GetPlanVariantDetailsPurchaseLimit(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -1533,7 +1538,7 @@ class GetPlanVariantDetailsAutoTopupTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[GetPlanVariantDetailsPurchaseLimitTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -1552,7 +1557,7 @@ class GetPlanVariantDetailsAutoTopup(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[GetPlanVariantDetailsPurchaseLimit] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" diff --git a/others/python-sdk/src/autumn_sdk/models/insertinvoicesop.py b/others/python-sdk/src/autumn_sdk/models/insertinvoicesop.py new file mode 100644 index 0000000000..c7ebbe6eff --- /dev/null +++ b/others/python-sdk/src/autumn_sdk/models/insertinvoicesop.py @@ -0,0 +1,279 @@ +"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + +from __future__ import annotations +from autumn_sdk.types import ( + BaseModel, + Nullable, + OptionalNullable, + UNSET, + UNSET_SENTINEL, + UnrecognizedStr, +) +from autumn_sdk.utils import FieldMetadata, HeaderMetadata +import pydantic +from pydantic import model_serializer +from typing import List, Literal, Optional, Union +from typing_extensions import Annotated, NotRequired, TypedDict + + +class InsertInvoicesGlobalsTypedDict(TypedDict): + x_api_version: NotRequired[str] + + +class InsertInvoicesGlobals(BaseModel): + x_api_version: Annotated[ + Optional[str], + pydantic.Field(alias="x-api-version"), + FieldMetadata(header=HeaderMetadata(style="simple", explode=False)), + ] = "2.3.0" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["x-api-version"]) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + + if val != UNSET_SENTINEL: + if val is not None or k not in optional_fields: + m[k] = val + + return m + + +InsertInvoicesProcessorTypeRequest = Literal[ + "stripe", + "revenuecat", +] +r"""The billing processor that owns this invoice.""" + + +InsertInvoicesStatus = Literal[ + "draft", + "open", + "void", + "paid", + "uncollectible", +] +r"""The invoice status.""" + + +class InvoiceRequestBodyTypedDict(TypedDict): + customer_id: str + r"""The customer this invoice belongs to.""" + stripe_id: str + r"""The processor's stable invoice ID.""" + status: InsertInvoicesStatus + r"""The invoice status.""" + total: float + r"""The invoice total in major currency units.""" + created_at: float + r"""The invoice creation timestamp in milliseconds.""" + plan_ids: NotRequired[List[str]] + r"""Plan IDs represented by this invoice.""" + processor_type: NotRequired[InsertInvoicesProcessorTypeRequest] + r"""The billing processor that owns this invoice.""" + amount_paid: NotRequired[Nullable[float]] + r"""The amount paid in major currency units.""" + refunded_amount: NotRequired[float] + r"""The refunded amount in major currency units.""" + currency: NotRequired[str] + r"""The currency code. Defaults to the organization's default currency.""" + hosted_invoice_url: NotRequired[Nullable[str]] + r"""The hosted invoice URL, when available.""" + + +class InvoiceRequestBody(BaseModel): + customer_id: str + r"""The customer this invoice belongs to.""" + + stripe_id: str + r"""The processor's stable invoice ID.""" + + status: InsertInvoicesStatus + r"""The invoice status.""" + + total: float + r"""The invoice total in major currency units.""" + + created_at: float + r"""The invoice creation timestamp in milliseconds.""" + + plan_ids: Optional[List[str]] = None + r"""Plan IDs represented by this invoice.""" + + processor_type: Optional[InsertInvoicesProcessorTypeRequest] = "stripe" + r"""The billing processor that owns this invoice.""" + + amount_paid: OptionalNullable[float] = UNSET + r"""The amount paid in major currency units.""" + + refunded_amount: Optional[float] = 0 + r"""The refunded amount in major currency units.""" + + currency: Optional[str] = None + r"""The currency code. Defaults to the organization's default currency.""" + + hosted_invoice_url: OptionalNullable[str] = UNSET + r"""The hosted invoice URL, when available.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set( + [ + "plan_ids", + "processor_type", + "amount_paid", + "refunded_amount", + "currency", + "hosted_invoice_url", + ] + ) + nullable_fields = set(["amount_paid", "hosted_invoice_url"]) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + is_nullable_and_explicitly_set = ( + k in nullable_fields + and (self.__pydantic_fields_set__.intersection({n})) # pylint: disable=no-member + ) + + if val != UNSET_SENTINEL: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): + m[k] = val + + return m + + +class InsertInvoicesParamsTypedDict(TypedDict): + invoices: List[InvoiceRequestBodyTypedDict] + r"""Invoices to insert or update, in response order.""" + + +class InsertInvoicesParams(BaseModel): + invoices: List[InvoiceRequestBody] + r"""Invoices to insert or update, in response order.""" + + +InsertInvoicesProcessorTypeResponse = Union[ + Literal[ + "stripe", + "revenuecat", + ], + UnrecognizedStr, +] +r"""The billing processor that owns this invoice.""" + + +class InsertInvoicesInvoiceResponseTypedDict(TypedDict): + plan_ids: List[str] + r"""Array of plan IDs included in this invoice""" + stripe_id: str + r"""The Stripe invoice ID""" + status: str + r"""The status of the invoice""" + total: float + r"""The total amount of the invoice""" + currency: str + r"""The currency code for the invoice""" + created_at: float + r"""Timestamp when the invoice was created""" + id: str + r"""The Autumn invoice ID.""" + customer_id: str + r"""The customer this invoice belongs to.""" + amount_paid: Nullable[float] + r"""The amount paid in major currency units.""" + refunded_amount: float + r"""The refunded amount in major currency units.""" + processor_type: NotRequired[InsertInvoicesProcessorTypeResponse] + r"""The billing processor that owns this invoice.""" + hosted_invoice_url: NotRequired[Nullable[str]] + r"""URL to the Stripe-hosted invoice page""" + + +class InsertInvoicesInvoiceResponse(BaseModel): + plan_ids: List[str] + r"""Array of plan IDs included in this invoice""" + + stripe_id: str + r"""The Stripe invoice ID""" + + status: str + r"""The status of the invoice""" + + total: float + r"""The total amount of the invoice""" + + currency: str + r"""The currency code for the invoice""" + + created_at: float + r"""Timestamp when the invoice was created""" + + id: str + r"""The Autumn invoice ID.""" + + customer_id: str + r"""The customer this invoice belongs to.""" + + amount_paid: Nullable[float] + r"""The amount paid in major currency units.""" + + refunded_amount: float + r"""The refunded amount in major currency units.""" + + processor_type: Optional[InsertInvoicesProcessorTypeResponse] = "stripe" + r"""The billing processor that owns this invoice.""" + + hosted_invoice_url: OptionalNullable[str] = UNSET + r"""URL to the Stripe-hosted invoice page""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["processor_type", "hosted_invoice_url"]) + nullable_fields = set(["hosted_invoice_url", "amount_paid"]) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + is_nullable_and_explicitly_set = ( + k in nullable_fields + and (self.__pydantic_fields_set__.intersection({n})) # pylint: disable=no-member + ) + + if val != UNSET_SENTINEL: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): + m[k] = val + + return m + + +class InsertInvoicesResponseTypedDict(TypedDict): + r"""OK""" + + invoices: List[InsertInvoicesInvoiceResponseTypedDict] + r"""Inserted or updated invoices in request order.""" + + +class InsertInvoicesResponse(BaseModel): + r"""OK""" + + invoices: List[InsertInvoicesInvoiceResponse] + r"""Inserted or updated invoices in request order.""" diff --git a/others/python-sdk/src/autumn_sdk/models/listinvoicesop.py b/others/python-sdk/src/autumn_sdk/models/listinvoicesop.py index 66bfca14d6..82723c2121 100644 --- a/others/python-sdk/src/autumn_sdk/models/listinvoicesop.py +++ b/others/python-sdk/src/autumn_sdk/models/listinvoicesop.py @@ -53,7 +53,7 @@ def serialize_model(self, handler): ] -ProcessorTypeRequest = Literal[ +ListInvoicesProcessorTypeRequest = Literal[ "stripe", "revenuecat", ] @@ -70,7 +70,7 @@ class ListInvoicesParamsTypedDict(TypedDict): r"""Filter invoices to a single entity by ID. Must be provided together with customer_id, since entity IDs are only unique per customer.""" status: NotRequired[List[ListInvoicesStatus]] r"""Filter by invoice status (draft, open, paid, void, uncollectible).""" - processor_types: NotRequired[List[ProcessorTypeRequest]] + processor_types: NotRequired[List[ListInvoicesProcessorTypeRequest]] r"""Filter by billing processor (stripe, revenuecat). Invoices recorded before processor tracking count as stripe.""" @@ -90,7 +90,7 @@ class ListInvoicesParams(BaseModel): status: Optional[List[ListInvoicesStatus]] = None r"""Filter by invoice status (draft, open, paid, void, uncollectible).""" - processor_types: Optional[List[ProcessorTypeRequest]] = None + processor_types: Optional[List[ListInvoicesProcessorTypeRequest]] = None r"""Filter by billing processor (stripe, revenuecat). Invoices recorded before processor tracking count as stripe.""" @model_serializer(mode="wrap") diff --git a/others/python-sdk/src/autumn_sdk/models/listplansop.py b/others/python-sdk/src/autumn_sdk/models/listplansop.py index 0f2b19fd6d..f58d48fbff 100644 --- a/others/python-sdk/src/autumn_sdk/models/listplansop.py +++ b/others/python-sdk/src/autumn_sdk/models/listplansop.py @@ -1450,7 +1450,7 @@ def serialize_model(self, handler): class ListPlansVariantDetailsPurchaseLimitTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: ListPlansVariantDetailsPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1458,10 +1458,12 @@ class ListPlansVariantDetailsPurchaseLimitTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class ListPlansVariantDetailsPurchaseLimit(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: ListPlansVariantDetailsPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1472,9 +1474,12 @@ class ListPlansVariantDetailsPurchaseLimit(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -1499,7 +1504,7 @@ class ListPlansVariantDetailsAutoTopupTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[ListPlansVariantDetailsPurchaseLimitTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -1518,7 +1523,7 @@ class ListPlansVariantDetailsAutoTopup(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[ListPlansVariantDetailsPurchaseLimit] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" diff --git a/others/python-sdk/src/autumn_sdk/models/plan.py b/others/python-sdk/src/autumn_sdk/models/plan.py index 883f3c3c7f..0ffd9b3918 100644 --- a/others/python-sdk/src/autumn_sdk/models/plan.py +++ b/others/python-sdk/src/autumn_sdk/models/plan.py @@ -1417,7 +1417,7 @@ def serialize_model(self, handler): class PlanVariantDetailsPurchaseLimitTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: PlanVariantDetailsPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1425,10 +1425,12 @@ class PlanVariantDetailsPurchaseLimitTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class PlanVariantDetailsPurchaseLimit(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: PlanVariantDetailsPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1439,9 +1441,12 @@ class PlanVariantDetailsPurchaseLimit(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -1466,7 +1471,7 @@ class PlanVariantDetailsAutoTopupTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[PlanVariantDetailsPurchaseLimitTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -1485,7 +1490,7 @@ class PlanVariantDetailsAutoTopup(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[PlanVariantDetailsPurchaseLimit] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" diff --git a/others/python-sdk/src/autumn_sdk/models/previewattachop.py b/others/python-sdk/src/autumn_sdk/models/previewattachop.py index 8dd937dd8a..6a6f1ad737 100644 --- a/others/python-sdk/src/autumn_sdk/models/previewattachop.py +++ b/others/python-sdk/src/autumn_sdk/models/previewattachop.py @@ -1142,7 +1142,7 @@ def serialize_model(self, handler): class PreviewAttachPurchaseLimitTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: PreviewAttachPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1150,10 +1150,12 @@ class PreviewAttachPurchaseLimitTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class PreviewAttachPurchaseLimit(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: PreviewAttachPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1164,9 +1166,12 @@ class PreviewAttachPurchaseLimit(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -1191,7 +1196,7 @@ class PreviewAttachAutoTopupTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[PreviewAttachPurchaseLimitTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -1210,7 +1215,7 @@ class PreviewAttachAutoTopup(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[PreviewAttachPurchaseLimit] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" diff --git a/others/python-sdk/src/autumn_sdk/models/previewupdateop.py b/others/python-sdk/src/autumn_sdk/models/previewupdateop.py index 25e80afc47..0054391992 100644 --- a/others/python-sdk/src/autumn_sdk/models/previewupdateop.py +++ b/others/python-sdk/src/autumn_sdk/models/previewupdateop.py @@ -1142,7 +1142,7 @@ def serialize_model(self, handler): class PreviewUpdatePurchaseLimitTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: PreviewUpdatePurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1150,10 +1150,12 @@ class PreviewUpdatePurchaseLimitTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class PreviewUpdatePurchaseLimit(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: PreviewUpdatePurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1164,9 +1166,12 @@ class PreviewUpdatePurchaseLimit(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -1191,7 +1196,7 @@ class PreviewUpdateAutoTopupTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[PreviewUpdatePurchaseLimitTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -1210,7 +1215,7 @@ class PreviewUpdateAutoTopup(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[PreviewUpdatePurchaseLimit] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" diff --git a/others/python-sdk/src/autumn_sdk/models/setuppaymentop.py b/others/python-sdk/src/autumn_sdk/models/setuppaymentop.py index 8762148494..999805c95f 100644 --- a/others/python-sdk/src/autumn_sdk/models/setuppaymentop.py +++ b/others/python-sdk/src/autumn_sdk/models/setuppaymentop.py @@ -1138,7 +1138,7 @@ def serialize_model(self, handler): class SetupPaymentPurchaseLimitTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: SetupPaymentPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1146,10 +1146,12 @@ class SetupPaymentPurchaseLimitTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class SetupPaymentPurchaseLimit(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: SetupPaymentPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -1160,9 +1162,12 @@ class SetupPaymentPurchaseLimit(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -1187,7 +1192,7 @@ class SetupPaymentAutoTopupTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[SetupPaymentPurchaseLimitTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -1206,7 +1211,7 @@ class SetupPaymentAutoTopup(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[SetupPaymentPurchaseLimit] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" diff --git a/others/python-sdk/src/autumn_sdk/models/updatecustomerop.py b/others/python-sdk/src/autumn_sdk/models/updatecustomerop.py index 2f9f14dd4c..85750af208 100644 --- a/others/python-sdk/src/autumn_sdk/models/updatecustomerop.py +++ b/others/python-sdk/src/autumn_sdk/models/updatecustomerop.py @@ -56,7 +56,7 @@ def serialize_model(self, handler): class UpdateCustomerPurchaseLimitRequestTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: UpdateCustomerPurchaseLimitIntervalRequestBody r"""The time interval for the purchase limit window.""" @@ -64,10 +64,12 @@ class UpdateCustomerPurchaseLimitRequestTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class UpdateCustomerPurchaseLimitRequest(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: UpdateCustomerPurchaseLimitIntervalRequestBody r"""The time interval for the purchase limit window.""" @@ -78,9 +80,12 @@ class UpdateCustomerPurchaseLimitRequest(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -105,7 +110,7 @@ class UpdateCustomerAutoTopupRequestTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[UpdateCustomerPurchaseLimitRequestTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -124,7 +129,7 @@ class UpdateCustomerAutoTopupRequest(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[UpdateCustomerPurchaseLimitRequest] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" diff --git a/others/python-sdk/src/autumn_sdk/models/updateplanop.py b/others/python-sdk/src/autumn_sdk/models/updateplanop.py index 7bfbabf54d..23ff21a47d 100644 --- a/others/python-sdk/src/autumn_sdk/models/updateplanop.py +++ b/others/python-sdk/src/autumn_sdk/models/updateplanop.py @@ -1158,8 +1158,6 @@ class UpdatePlanLicenseTypedDict(TypedDict): prepaid_only: NotRequired[bool] customize: NotRequired[Nullable[UpdatePlanLicenseCustomizeTypedDict]] metadata: NotRequired[Dict[str, Any]] - version: NotRequired[int] - r"""Pin the link to a specific version of the license plan. Omitted, an existing link keeps its pinned version and a new link resolves to the latest.""" class UpdatePlanLicense(BaseModel): @@ -1173,14 +1171,9 @@ class UpdatePlanLicense(BaseModel): metadata: Optional[Dict[str, Any]] = None - version: Optional[int] = None - r"""Pin the link to a specific version of the license plan. Omitted, an existing link keeps its pinned version and a new link resolves to the latest.""" - @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set( - ["included", "prepaid_only", "customize", "metadata", "version"] - ) + optional_fields = set(["included", "prepaid_only", "customize", "metadata"]) nullable_fields = set(["customize"]) serialized = handler(self) m = {} @@ -1304,7 +1297,7 @@ def serialize_model(self, handler): class UpdatePlanPurchaseLimitRequestTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: UpdatePlanPurchaseLimitIntervalRequestBody r"""The time interval for the purchase limit window.""" @@ -1312,10 +1305,12 @@ class UpdatePlanPurchaseLimitRequestTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class UpdatePlanPurchaseLimitRequest(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: UpdatePlanPurchaseLimitIntervalRequestBody r"""The time interval for the purchase limit window.""" @@ -1326,9 +1321,12 @@ class UpdatePlanPurchaseLimitRequest(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -1353,7 +1351,7 @@ class UpdatePlanAutoTopupRequestTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[UpdatePlanPurchaseLimitRequestTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -1372,7 +1370,7 @@ class UpdatePlanAutoTopupRequest(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[UpdatePlanPurchaseLimitRequest] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -1703,6 +1701,17 @@ def serialize_model(self, handler): return m +class UpdateLicenseParentTypedDict(TypedDict): + plan_id: str + version: int + + +class UpdateLicenseParent(BaseModel): + plan_id: str + + version: int + + PriceVariantInterval = Literal[ "one_off", "week", @@ -2333,7 +2342,7 @@ def serialize_model(self, handler): class VariantPurchaseLimitTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: VariantPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -2341,10 +2350,12 @@ class VariantPurchaseLimitTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class VariantPurchaseLimit(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: VariantPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -2355,9 +2366,12 @@ class VariantPurchaseLimit(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -2382,7 +2396,7 @@ class VariantAutoTopupTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[VariantPurchaseLimitTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -2401,7 +2415,7 @@ class VariantAutoTopup(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[VariantPurchaseLimit] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -2886,6 +2900,8 @@ class UpdatePlanParamsTypedDict(TypedDict): r"""Force versioning even when no customers exist. Mutually exclusive with disable_version.""" update_variant_ids: NotRequired[List[str]] r"""Variant plan IDs to apply this update to. Empty or omitted means no propagation.""" + update_license_parents: NotRequired[List[UpdateLicenseParentTypedDict]] + r"""Parent plan versions that should receive this license-plan update.""" variants: NotRequired[List[VariantTypedDict]] r"""Additive variant updates for this base plan. Missing variants are created when name is provided.""" is_default: NotRequired[bool] @@ -2955,6 +2971,9 @@ class UpdatePlanParams(BaseModel): update_variant_ids: Optional[List[str]] = None r"""Variant plan IDs to apply this update to. Empty or omitted means no propagation.""" + update_license_parents: Optional[List[UpdateLicenseParent]] = None + r"""Parent plan versions that should receive this license-plan update.""" + variants: Optional[List[Variant]] = None r"""Additive variant updates for this base plan. Missing variants are created when name is provided.""" @@ -2987,6 +3006,7 @@ def serialize_model(self, handler): "migration", "force_version", "update_variant_ids", + "update_license_parents", "variants", "is_default", ] @@ -4447,7 +4467,7 @@ def serialize_model(self, handler): class UpdatePlanVariantDetailsPurchaseLimitTypedDict(TypedDict): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: UpdatePlanVariantDetailsPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -4455,10 +4475,12 @@ class UpdatePlanVariantDetailsPurchaseLimitTypedDict(TypedDict): r"""Maximum number of auto top-ups allowed within the interval.""" interval_count: NotRequired[float] r"""Number of intervals in the purchase limit window.""" + count: NotRequired[float] + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" class UpdatePlanVariantDetailsPurchaseLimit(BaseModel): - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" interval: UpdatePlanVariantDetailsPurchaseLimitInterval r"""The time interval for the purchase limit window.""" @@ -4469,9 +4491,12 @@ class UpdatePlanVariantDetailsPurchaseLimit(BaseModel): interval_count: Optional[float] = 1 r"""Number of intervals in the purchase limit window.""" + count: Optional[float] = None + r"""Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["interval_count"]) + optional_fields = set(["interval_count", "count"]) serialized = handler(self) m = {} @@ -4496,7 +4521,7 @@ class UpdatePlanVariantDetailsAutoTopupTypedDict(TypedDict): enabled: NotRequired[bool] r"""Whether auto top-up is enabled.""" purchase_limit: NotRequired[UpdatePlanVariantDetailsPurchaseLimitTypedDict] - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: NotRequired[bool] r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" @@ -4515,7 +4540,7 @@ class UpdatePlanVariantDetailsAutoTopup(BaseModel): r"""Whether auto top-up is enabled.""" purchase_limit: Optional[UpdatePlanVariantDetailsPurchaseLimit] = None - r"""Optional rate limit to cap how often auto top-ups occur.""" + r"""Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.""" invoice_mode: Optional[bool] = None r"""When true, auto top-up creates a send_invoice invoice instead of auto-charging.""" diff --git a/others/python-sdk/src/autumn_sdk/plans.py b/others/python-sdk/src/autumn_sdk/plans.py index bdc12bb667..3baac7b5e7 100644 --- a/others/python-sdk/src/autumn_sdk/plans.py +++ b/others/python-sdk/src/autumn_sdk/plans.py @@ -789,6 +789,12 @@ def update( migration: Optional[Union[models.Migration, models.MigrationTypedDict]] = None, force_version: Optional[bool] = None, update_variant_ids: Optional[List[str]] = None, + update_license_parents: Optional[ + Union[ + List[models.UpdateLicenseParent], + List[models.UpdateLicenseParentTypedDict], + ] + ] = None, variants: Optional[ Union[List[models.Variant], List[models.VariantTypedDict]] ] = None, @@ -827,6 +833,7 @@ def update( :param migration: :param force_version: Force versioning even when no customers exist. Mutually exclusive with disable_version. :param update_variant_ids: Variant plan IDs to apply this update to. Empty or omitted means no propagation. + :param update_license_parents: Parent plan versions that should receive this license-plan update. :param variants: Additive variant updates for this base plan. Missing variants are created when name is provided. :param is_default: Whether this is the org's default plan. Cannot be true on a variant. :param retries: Override the default retry configuration for this method @@ -880,6 +887,9 @@ def update( migration=utils.get_pydantic_model(migration, Optional[models.Migration]), force_version=force_version, update_variant_ids=update_variant_ids, + update_license_parents=utils.get_pydantic_model( + update_license_parents, Optional[List[models.UpdateLicenseParent]] + ), variants=utils.get_pydantic_model(variants, Optional[List[models.Variant]]), is_default=is_default, ) @@ -997,6 +1007,12 @@ async def update_async( migration: Optional[Union[models.Migration, models.MigrationTypedDict]] = None, force_version: Optional[bool] = None, update_variant_ids: Optional[List[str]] = None, + update_license_parents: Optional[ + Union[ + List[models.UpdateLicenseParent], + List[models.UpdateLicenseParentTypedDict], + ] + ] = None, variants: Optional[ Union[List[models.Variant], List[models.VariantTypedDict]] ] = None, @@ -1035,6 +1051,7 @@ async def update_async( :param migration: :param force_version: Force versioning even when no customers exist. Mutually exclusive with disable_version. :param update_variant_ids: Variant plan IDs to apply this update to. Empty or omitted means no propagation. + :param update_license_parents: Parent plan versions that should receive this license-plan update. :param variants: Additive variant updates for this base plan. Missing variants are created when name is provided. :param is_default: Whether this is the org's default plan. Cannot be true on a variant. :param retries: Override the default retry configuration for this method @@ -1088,6 +1105,9 @@ async def update_async( migration=utils.get_pydantic_model(migration, Optional[models.Migration]), force_version=force_version, update_variant_ids=update_variant_ids, + update_license_parents=utils.get_pydantic_model( + update_license_parents, Optional[List[models.UpdateLicenseParent]] + ), variants=utils.get_pydantic_model(variants, Optional[List[models.Variant]]), is_default=is_default, ) diff --git a/others/python-sdk/uv.lock b/others/python-sdk/uv.lock index 52537c7b1d..eb7efb819e 100644 --- a/others/python-sdk/uv.lock +++ b/others/python-sdk/uv.lock @@ -151,11 +151,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] diff --git a/others/python-test/uv.lock b/others/python-test/uv.lock index ab4394a675..6e17324f14 100644 --- a/others/python-test/uv.lock +++ b/others/python-test/uv.lock @@ -25,7 +25,7 @@ wheels = [ [[package]] name = "autumn-sdk" -version = "1.0.0" +version = "0.4.18" source = { editable = "../python-sdk" } dependencies = [ { name = "httpcore" }, @@ -106,11 +106,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] diff --git a/package.json b/package.json index bfcf6487d4..94566909ba 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "d:test": "ENV_FILE=.env infisical run --env=test --recursive -- bun scripts/dev.ts", "p": "ENV_FILE=.env.prod infisical run --env=prod --recursive -- bun scripts/dev.ts", "s": "ENV_FILE=.env.staging infisical run --env=staging --recursive -- bun scripts/dev.ts", + "reset-v2": "bun server/src/cron/resetV2.ts", "l": "bash ./scripts/dev-local.sh", "setup": "node scripts/setup/setup.js", "contracts": "infisical run --env=dev --recursive -- bun scripts/s3/contracts.ts", diff --git a/packages/autumn-js/src/generated/attachSchemas.ts b/packages/autumn-js/src/generated/attachSchemas.ts index 50efbb3062..57e016ba10 100644 --- a/packages/autumn-js/src/generated/attachSchemas.ts +++ b/packages/autumn-js/src/generated/attachSchemas.ts @@ -326,6 +326,7 @@ export const attachPurchaseLimitOutboundSchema = z.object({ interval: z.string(), interval_count: z.number(), limit: z.number(), + count: z.union([z.number(), z.undefined()]).optional(), }); export const attachAutoTopupOutboundSchema = z.object({ @@ -803,6 +804,7 @@ export const attachPurchaseLimitSchema = z.object({ interval: attachPurchaseLimitIntervalSchema, intervalCount: z.union([z.number(), z.undefined()]).optional(), limit: z.number(), + count: z.union([z.number(), z.undefined()]).optional(), }); export const attachAutoTopupSchema = z.object({ diff --git a/packages/autumn-js/src/generated/getOrCreateCustomerSchemas.ts b/packages/autumn-js/src/generated/getOrCreateCustomerSchemas.ts index ec9c9e8bd8..1041c185b3 100644 --- a/packages/autumn-js/src/generated/getOrCreateCustomerSchemas.ts +++ b/packages/autumn-js/src/generated/getOrCreateCustomerSchemas.ts @@ -32,6 +32,7 @@ export const getOrCreateCustomerPurchaseLimitOutboundSchema = z.object({ interval: z.string(), interval_count: z.number(), limit: z.number(), + count: z.union([z.number(), z.undefined()]).optional(), }); export const getOrCreateCustomerAutoTopupOutboundSchema = z.object({ @@ -157,6 +158,7 @@ export const getOrCreateCustomerPurchaseLimitSchema = z.object({ interval: getOrCreateCustomerPurchaseLimitIntervalSchema, intervalCount: z.union([z.number(), z.undefined()]).optional(), limit: z.number(), + count: z.union([z.number(), z.undefined()]).optional(), }); export const getOrCreateCustomerAutoTopupSchema = z.object({ diff --git a/packages/autumn-js/src/generated/listPlansSchemas.ts b/packages/autumn-js/src/generated/listPlansSchemas.ts index 725abbb316..797c112da4 100644 --- a/packages/autumn-js/src/generated/listPlansSchemas.ts +++ b/packages/autumn-js/src/generated/listPlansSchemas.ts @@ -325,6 +325,7 @@ export const listPlansVariantDetailsPurchaseLimitSchema = z.object({ interval: listPlansVariantDetailsPurchaseLimitIntervalSchema, intervalCount: z.number(), limit: z.number(), + count: z.union([z.number(), z.undefined()]).optional(), }); export const listPlansVariantDetailsAutoTopupSchema = z.object({ diff --git a/packages/autumn-js/src/generated/previewAttachSchemas.ts b/packages/autumn-js/src/generated/previewAttachSchemas.ts index f2f5b7f3be..517a438354 100644 --- a/packages/autumn-js/src/generated/previewAttachSchemas.ts +++ b/packages/autumn-js/src/generated/previewAttachSchemas.ts @@ -439,6 +439,7 @@ export const previewAttachPurchaseLimitOutboundSchema = z.object({ interval: z.string(), interval_count: z.number(), limit: z.number(), + count: z.union([z.number(), z.undefined()]).optional(), }); export const previewAttachAutoTopupOutboundSchema = z.object({ @@ -951,6 +952,7 @@ export const previewAttachPurchaseLimitSchema = z.object({ interval: previewAttachPurchaseLimitIntervalSchema, intervalCount: z.union([z.number(), z.undefined()]).optional(), limit: z.number(), + count: z.union([z.number(), z.undefined()]).optional(), }); export const previewAttachAutoTopupSchema = z.object({ diff --git a/packages/autumn-js/src/generated/previewUpdateSubscriptionSchemas.ts b/packages/autumn-js/src/generated/previewUpdateSubscriptionSchemas.ts index 1e2786b1e0..fe8c006f0b 100644 --- a/packages/autumn-js/src/generated/previewUpdateSubscriptionSchemas.ts +++ b/packages/autumn-js/src/generated/previewUpdateSubscriptionSchemas.ts @@ -433,6 +433,7 @@ export const previewUpdatePurchaseLimitOutboundSchema = z.object({ interval: z.string(), interval_count: z.number(), limit: z.number(), + count: z.union([z.number(), z.undefined()]).optional(), }); export const previewUpdateAutoTopupOutboundSchema = z.object({ @@ -922,6 +923,7 @@ export const previewUpdatePurchaseLimitSchema = z.object({ interval: previewUpdatePurchaseLimitIntervalSchema, intervalCount: z.union([z.number(), z.undefined()]).optional(), limit: z.number(), + count: z.union([z.number(), z.undefined()]).optional(), }); export const previewUpdateAutoTopupSchema = z.object({ diff --git a/packages/autumn-js/src/generated/setupPaymentSchemas.ts b/packages/autumn-js/src/generated/setupPaymentSchemas.ts index 70524a9d48..2708395b49 100644 --- a/packages/autumn-js/src/generated/setupPaymentSchemas.ts +++ b/packages/autumn-js/src/generated/setupPaymentSchemas.ts @@ -345,6 +345,7 @@ export const setupPaymentPurchaseLimitOutboundSchema = z.object({ interval: z.string(), interval_count: z.number(), limit: z.number(), + count: z.union([z.number(), z.undefined()]).optional(), }); export const setupPaymentAutoTopupOutboundSchema = z.object({ @@ -829,6 +830,7 @@ export const setupPaymentPurchaseLimitSchema = z.object({ interval: setupPaymentPurchaseLimitIntervalSchema, intervalCount: z.union([z.number(), z.undefined()]).optional(), limit: z.number(), + count: z.union([z.number(), z.undefined()]).optional(), }); export const setupPaymentAutoTopupSchema = z.object({ diff --git a/packages/autumn-js/src/generated/updateSubscriptionSchemas.ts b/packages/autumn-js/src/generated/updateSubscriptionSchemas.ts index 2a6d6dc59f..d62753c373 100644 --- a/packages/autumn-js/src/generated/updateSubscriptionSchemas.ts +++ b/packages/autumn-js/src/generated/updateSubscriptionSchemas.ts @@ -348,6 +348,7 @@ export const billingUpdatePurchaseLimitOutboundSchema = z.object({ interval: z.string(), interval_count: z.number(), limit: z.number(), + count: z.union([z.number(), z.undefined()]).optional(), }); export const billingUpdateAutoTopupOutboundSchema = z.object({ @@ -832,6 +833,7 @@ export const billingUpdatePurchaseLimitSchema = z.object({ interval: billingUpdatePurchaseLimitIntervalSchema, intervalCount: z.union([z.number(), z.undefined()]).optional(), limit: z.number(), + count: z.union([z.number(), z.undefined()]).optional(), }); export const billingUpdateAutoTopupSchema = z.object({ diff --git a/packages/mcp/src/tools/org.ts b/packages/mcp/src/tools/org.ts index ab567d5e3d..8356141afa 100644 --- a/packages/mcp/src/tools/org.ts +++ b/packages/mcp/src/tools/org.ts @@ -4,13 +4,19 @@ import { getAutumnAuth } from "../server/auth/auth.js"; import { mcpAnnotations } from "./utils/annotations.js"; import { callAutumnGet } from "./utils/client.js"; -const organizationMeSchema = z - .object({ - name: z.string(), - slug: z.string(), - env: z.string(), - }) - .strict(); +const organizationMeSchema = z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + env: z.string(), + user: z + .object({ + id: z.string(), + email: z.string(), + name: z.string(), + }) + .nullish(), +}); const signalOf = (context: { mcp?: { extra?: { signal?: AbortSignal } } }) => context?.mcp?.extra?.signal; diff --git a/packages/mcp/tests/unit/mcp-server/agent/tools.test.ts b/packages/mcp/tests/unit/mcp-server/agent/tools.test.ts index 00c5b1a29a..2ccac48160 100644 --- a/packages/mcp/tests/unit/mcp-server/agent/tools.test.ts +++ b/packages/mcp/tests/unit/mcp-server/agent/tools.test.ts @@ -557,9 +557,15 @@ describe("Autumn operation tools", () => { expect(init?.method).toBe("GET"); expect(init?.body).toBeUndefined(); return Response.json({ + id: "org_unit_tests", name: "Unit Tests", slug: "unit-tests", env: "sandbox", + user: { + id: "user_unit_tests", + email: "unit@tests.dev", + name: "Unit Tester", + }, }); }) as typeof fetch; @@ -575,9 +581,15 @@ describe("Autumn operation tools", () => { { mcp: { extra: { authInfo: auth } } } as never, ), ).resolves.toEqual({ + id: "org_unit_tests", name: "Unit Tests", slug: "unit-tests", env: "sandbox", + user: { + id: "user_unit_tests", + email: "unit@tests.dev", + name: "Unit Tester", + }, }); } finally { globalThis.fetch = originalFetch; diff --git a/packages/openapi/openapi-stripped.yml b/packages/openapi/openapi-stripped.yml index e3992d8763..a9e5c26f12 100644 --- a/packages/openapi/openapi-stripped.yml +++ b/packages/openapi/openapi-stripped.yml @@ -76,7 +76,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -103,10 +102,16 @@ components: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -339,7 +344,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -1996,7 +2000,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -2023,10 +2026,16 @@ components: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -2197,7 +2206,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -2831,7 +2839,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -2858,10 +2865,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -3160,7 +3173,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -4217,7 +4229,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -5075,7 +5086,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -5102,10 +5112,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -5341,7 +5357,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -6679,13 +6694,6 @@ paths: propertyNames: type: string additionalProperties: {} - version: - type: integer - minimum: 1 - maximum: 9007199254740991 - description: Pin the link to a specific version of the license plan. Omitted, an - existing link keeps its pinned version and a new link - resolves to the latest. required: - license_plan_id description: Plans offered as assignable licenses under this plan. The full set @@ -6748,7 +6756,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -6775,10 +6782,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -7740,7 +7753,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -7767,10 +7779,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed + top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -7944,7 +7963,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -9024,7 +9042,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -9051,10 +9068,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed + top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -9228,7 +9252,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -10314,7 +10337,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -10341,10 +10363,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's + consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -10519,7 +10548,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -11389,13 +11417,6 @@ paths: propertyNames: type: string additionalProperties: {} - version: - type: integer - minimum: 1 - maximum: 9007199254740991 - description: Pin the link to a specific version of the license plan. Omitted, an - existing link keeps its pinned version and a new link - resolves to the latest. required: - license_plan_id description: Plans offered as assignable licenses under this plan. The full set @@ -11461,7 +11482,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -11488,10 +11508,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -11689,6 +11715,23 @@ paths: type: string description: Variant plan IDs to apply this update to. Empty or omitted means no propagation. + update_license_parents: + type: array + items: + type: object + properties: + plan_id: + type: string + minLength: 1 + pattern: ^[a-zA-Z0-9_-]+$ + version: + type: integer + minimum: 1 + maximum: 9007199254740991 + required: + - plan_id + - version + description: Parent plan versions that should receive this license-plan update. variants: type: array items: @@ -12044,7 +12087,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -12071,10 +12113,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed + top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -13029,7 +13078,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -13056,10 +13104,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed + top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -13233,7 +13288,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -15057,7 +15111,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -15084,10 +15137,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -16478,7 +16537,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -16505,10 +16563,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's + consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -17179,7 +17244,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -17206,10 +17270,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's + consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -18790,7 +18861,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -18817,10 +18887,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -21507,7 +21583,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -21534,10 +21609,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -22833,7 +22914,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -22860,10 +22940,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -25176,7 +25262,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -25203,10 +25288,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -27139,7 +27230,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -27916,7 +28006,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -29538,6 +29627,198 @@ paths: x-speakeasy-name-override: aggregate parameters: - *a5 + /v1/invoices.insert: + post: + operationId: insertInvoices + description: Inserts or updates up to 500 historical invoices without reading or + mutating the billing processor. + tags: + - invoices + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + invoices: + type: array + maxItems: 500 + items: + type: object + properties: + customer_id: + type: string + description: The customer this invoice belongs to. + plan_ids: + type: array + items: + type: string + default: [] + description: Plan IDs represented by this invoice. + stripe_id: + type: string + description: The processor's stable invoice ID. + processor_type: + enum: + - stripe + - revenuecat + type: string + default: stripe + description: The billing processor that owns this invoice. + status: + enum: + - draft + - open + - void + - paid + - uncollectible + type: string + description: The invoice status. + total: + type: number + description: The invoice total in major currency units. + amount_paid: + anyOf: + - type: number + - type: "null" + default: null + description: The amount paid in major currency units. + refunded_amount: + type: number + default: 0 + description: The refunded amount in major currency units. + currency: + type: string + description: The currency code. Defaults to the organization's default currency. + created_at: + type: number + description: The invoice creation timestamp in milliseconds. + hosted_invoice_url: + anyOf: + - type: string + - type: "null" + default: null + description: The hosted invoice URL, when available. + required: + - customer_id + - stripe_id + - status + - total + - created_at + description: Invoices to insert or update, in response order. + required: + - invoices + title: InsertInvoicesParams + examples: + - &a72 + invoices: + - customer_id: cus_123 + plan_ids: + - pro + stripe_id: in_legacy_123 + processor_type: stripe + status: paid + total: 29.99 + amount_paid: 29.99 + refunded_amount: 0 + currency: usd + created_at: 1451606400000 + hosted_invoice_url: https://billing.example.com/invoices/legacy-123 + example: *a72 + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + invoices: + type: array + items: + type: object + properties: + plan_ids: + type: array + items: + type: string + description: Array of plan IDs included in this invoice + stripe_id: + type: string + description: The Stripe invoice ID + processor_type: + enum: + - stripe + - revenuecat + type: string + default: stripe + description: The billing processor that owns this invoice. + status: + type: string + description: The status of the invoice + total: + type: number + description: The total amount of the invoice + currency: + type: string + description: The currency code for the invoice + created_at: + type: number + description: Timestamp when the invoice was created + hosted_invoice_url: + anyOf: + - type: string + - type: "null" + description: URL to the Stripe-hosted invoice page + id: + type: string + description: The Autumn invoice ID. + customer_id: + type: string + description: The customer this invoice belongs to. + amount_paid: + anyOf: + - type: number + - type: "null" + description: The amount paid in major currency units. + refunded_amount: + type: number + description: The refunded amount in major currency units. + required: + - plan_ids + - stripe_id + - status + - total + - currency + - created_at + - id + - customer_id + - amount_paid + - refunded_amount + description: Inserted or updated invoices in request order. + required: + - invoices + examples: + - &a73 + invoices: + - id: inv_2b3c4d5e6f7g8h + customer_id: cus_123 + plan_ids: + - pro + stripe_id: in_legacy_123 + processor_type: stripe + status: paid + total: 29.99 + amount_paid: 29.99 + refunded_amount: 0 + currency: usd + created_at: 1451606400000 + hosted_invoice_url: https://billing.example.com/invoices/legacy-123 + example: *a73 + x-speakeasy-name-override: insert + parameters: + - *a5 /v1/invoices.list: post: operationId: listInvoices @@ -29596,14 +29877,14 @@ paths: before processor tracking count as stripe. title: ListInvoicesParams examples: - - &a72 + - &a74 start_cursor: "" limit: 10 customer_id: cus_123 status: - open - paid - example: *a72 + example: *a74 responses: "200": description: OK @@ -29695,7 +29976,7 @@ paths: - list - next_cursor examples: - - &a73 + - &a75 list: - id: inv_2b3c4d5e6f7g8h customer_id: cus_123 @@ -29712,7 +29993,7 @@ paths: created_at: 1759247877000 hosted_invoice_url: https://invoice.stripe.com/i/acct_123/test_456 next_cursor: null - example: *a73 + example: *a75 x-speakeasy-name-override: list parameters: - *a5 @@ -29759,13 +30040,13 @@ paths: - entities title: AttachLicenseParams examples: - - &a74 + - &a76 customer_id: cus_123 plan_id: seat_plan entities: - entity_id: user_123 name: Ada Lovelace - example: *a74 + example: *a76 responses: "200": description: OK @@ -29809,12 +30090,12 @@ paths: - entity_ids title: ReleaseLicenseParams examples: - - &a75 + - &a77 customer_id: cus_123 license_plan_id: seat_plan entity_ids: - user_123 - example: *a75 + example: *a77 responses: "200": description: OK @@ -29856,10 +30137,10 @@ paths: - customer_id title: ListLicenseAssignmentsParams examples: - - &a76 + - &a78 customer_id: cus_123 active: true - example: *a76 + example: *a78 responses: "200": description: OK @@ -29894,14 +30175,14 @@ paths: required: - list examples: - - &a77 + - &a79 list: - id: lic_asn_123 entity_id: user_123 license_plan_id: seat_plan started_at: 1759247877000 ended_at: null - example: *a77 + example: *a79 x-speakeasy-name-override: listAssignments x-speakeasy-ignore: true parameters: @@ -29927,9 +30208,9 @@ paths: - customer_id title: ListLicensesParams examples: - - &a78 + - &a80 customer_id: cus_123 - example: *a78 + example: *a80 responses: "200": description: OK @@ -29975,7 +30256,7 @@ paths: required: - list examples: - - &a79 + - &a81 list: - license_plan_id: seat_plan parent_plan_id: pro_plan @@ -29984,7 +30265,7 @@ paths: usage: 3 remaining: 7 paid_quantity: 5 - example: *a79 + example: *a81 x-speakeasy-name-override: list x-speakeasy-ignore: true parameters: @@ -30170,12 +30451,12 @@ paths: - entity_id title: CreateEntityParams examples: - - &a80 + - &a82 customer_id: cus_123 entity_id: seat_42 feature_id: seats name: Seat 42 - example: *a80 + example: *a82 responses: "200": description: OK @@ -30706,7 +30987,7 @@ paths: - balances - flags examples: - - &a81 + - &a83 id: seat_42 name: Seat 42 customer_id: cus_123 @@ -30751,7 +31032,7 @@ paths: price: null expires_at: null invoices: [] - example: *a81 + example: *a83 x-speakeasy-name-override: create parameters: - *a5 @@ -30783,11 +31064,11 @@ paths: - entity_id title: GetEntityParams examples: - - &a82 + - &a84 entity_id: seat_42 - customer_id: cus_123 entity_id: seat_42 - example: *a82 + example: *a84 responses: "200": description: OK @@ -31318,7 +31599,7 @@ paths: - balances - flags examples: - - &a83 + - &a85 id: seat_42 name: Seat 42 customer_id: cus_123 @@ -31363,7 +31644,7 @@ paths: price: null expires_at: null invoices: [] - example: *a83 + example: *a85 x-speakeasy-name-override: get parameters: - *a5 @@ -31442,12 +31723,12 @@ paths: paginated call instead of iterating entities.get. title: ListEntitiesParams examples: - - &a84 + - &a86 start_cursor: "" limit: 10 - plans: - id: pro_plan - example: *a84 + example: *a86 responses: "200": description: OK @@ -31999,7 +32280,7 @@ paths: - list - next_cursor examples: - - &a85 + - &a87 list: - id: seat_42 name: Seat 42 @@ -32046,7 +32327,7 @@ paths: expires_at: null invoices: [] next_cursor: null - example: *a85 + example: *a87 x-speakeasy-name-override: list parameters: - *a5 @@ -32216,7 +32497,7 @@ paths: - entity_id title: UpdateEntityParams examples: - - &a86 + - &a88 customer_id: cus_123 entity_id: seat_42 billing_controls: @@ -32225,7 +32506,7 @@ paths: enabled: true limit_type: usage_percentage overage_limit: 120 - example: *a86 + example: *a88 responses: "200": description: OK @@ -32756,7 +33037,7 @@ paths: - balances - flags examples: - - &a87 + - &a89 id: seat_42 name: Seat 42 customer_id: cus_123 @@ -32801,7 +33082,7 @@ paths: price: null expires_at: null invoices: [] - example: *a87 + example: *a89 x-speakeasy-name-override: update parameters: - *a5 @@ -32833,10 +33114,10 @@ paths: - entity_id title: DeleteEntityParams examples: - - &a88 + - &a90 customer_id: cus_123 entity_id: seat_42 - example: *a88 + example: *a90 responses: "200": description: OK @@ -32850,9 +33131,9 @@ paths: required: - success examples: - - &a89 + - &a91 success: true - example: *a89 + example: *a91 x-speakeasy-name-override: delete parameters: - *a5 @@ -32880,10 +33161,10 @@ paths: - program_id title: CreateReferralCodeParams examples: - - &a90 + - &a92 customer_id: cus_123 program_id: prog_123 - example: *a90 + example: *a92 responses: "200": description: OK @@ -32906,11 +33187,11 @@ paths: - customer_id - created_at examples: - - &a91 + - &a93 code: customer_id: created_at: 123 - example: *a91 + example: *a93 x-speakeasy-name-override: createCode parameters: - *a5 @@ -32938,10 +33219,10 @@ paths: - customer_id title: RedeemReferralCodeParams examples: - - &a92 + - &a94 code: REF123 customer_id: cus_456 - example: *a92 + example: *a94 responses: "200": description: OK @@ -32964,11 +33245,11 @@ paths: - customer_id - reward_id examples: - - &a93 + - &a95 id: customer_id: reward_id: - example: *a93 + example: *a95 x-speakeasy-name-override: redeemCode parameters: - *a5 @@ -32986,8 +33267,8 @@ paths: type: object title: RewardsListParams examples: - - &a94 {} - example: *a94 + - &a96 {} + example: *a96 responses: "200": description: OK @@ -33201,10 +33482,10 @@ paths: - coupons - feature_grants examples: - - &a95 + - &a97 coupons: [] feature_grants: [] - example: *a95 + example: *a97 x-speakeasy-name-override: list parameters: - *a5 @@ -33232,10 +33513,10 @@ paths: - customer_id title: RedeemRewardCodeParams examples: - - &a96 + - &a98 code: REWARD10 customer_id: cus_456 - example: *a96 + example: *a98 responses: "200": description: OK @@ -33266,12 +33547,12 @@ paths: - reward_id - entitlements_granted examples: - - &a97 + - &a99 reward_id: reward_789 entitlements_granted: - feature_id: messages balance: 100 - example: *a97 + example: *a99 x-speakeasy-name-override: redeemCode parameters: - *a5 @@ -33310,12 +33591,12 @@ paths: - redirect_url title: LinkRevenueCatParams examples: - - &a98 + - &a100 organization_slug: acme env: test project_name: acme-mobile redirect_url: https://dashboard.useautumn.com/dev?tab=revenuecat - example: *a98 + example: *a100 responses: "200": description: OK @@ -33330,9 +33611,9 @@ paths: - oauth_url title: LinkRevenueCatResponse examples: - - &a99 + - &a101 oauth_url: https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write - example: *a99 + example: *a101 x-speakeasy-name-override: linkRevenueCat parameters: - *a5 @@ -33372,13 +33653,13 @@ paths: - env title: SyncRevenueCatParams examples: - - &a100 + - &a102 organization_slug: acme env: test product_ids: - pro - premium - example: *a100 + example: *a102 responses: "200": description: OK @@ -33444,7 +33725,7 @@ paths: - results title: SyncRevenueCatResponse examples: - - &a101 + - &a103 results: - plan_id: pro status: synced @@ -33455,7 +33736,7 @@ paths: product: created store_push: skipped price: set - example: *a101 + example: *a103 x-speakeasy-name-override: syncRevenueCat parameters: - *a5 @@ -33489,10 +33770,10 @@ paths: - env title: GetRevenueCatKeysParams examples: - - &a102 + - &a104 organization_slug: acme env: test - example: *a102 + example: *a104 responses: "200": description: OK @@ -33555,7 +33836,7 @@ paths: - oauth_access_token title: GetRevenueCatKeysResponse examples: - - &a103 + - &a105 apps: - app_id: app1a2b3c4d app_type: test_store @@ -33566,7 +33847,7 @@ paths: environment: production app_id: app1a2b3c4 oauth_access_token: atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ - example: *a103 + example: *a105 x-speakeasy-name-override: getRevenueCatKeys parameters: - *a5 @@ -33598,9 +33879,9 @@ paths: - customer_id title: MintKeyParams examples: - - &a104 + - &a106 customer_id: cus_123 - example: *a104 + example: *a106 responses: "200": description: OK @@ -33629,12 +33910,12 @@ paths: - access_token - expires_at examples: - - &a105 + - &a107 access_token: am_jwt_eyJhbGciOiJIUzI1NiJ9... refresh_token: am_jwt_eyJhbGciOiJIUzI1NiJ9... expires_at: 1781113864000 refresh_expires_at: 1781196664000 - example: *a105 + example: *a107 x-speakeasy-name-override: mint parameters: - *a5 @@ -33658,8 +33939,8 @@ paths: description: No body. The refresh token is supplied as the Bearer credential; the response is a freshly rotated access + refresh pair. examples: - - &a106 {} - example: *a106 + - &a108 {} + example: *a108 responses: "200": description: OK @@ -33688,12 +33969,12 @@ paths: - access_token - expires_at examples: - - &a107 + - &a109 access_token: am_jwt_eyJhbGciOiJIUzI1NiJ9... refresh_token: am_jwt_eyJhbGciOiJIUzI1NiJ9... expires_at: 1781113864000 refresh_expires_at: 1781196664000 - example: *a107 + example: *a109 x-speakeasy-name-override: refresh parameters: - *a5 @@ -33720,9 +34001,9 @@ paths: - customer_id title: RevokeKeyParams examples: - - &a108 + - &a110 customer_id: cus_123 - example: *a108 + example: *a110 responses: "200": description: OK @@ -33736,9 +34017,9 @@ paths: required: - revoked examples: - - &a109 + - &a111 revoked: true - example: *a109 + example: *a111 x-speakeasy-name-override: revoke parameters: - *a5 diff --git a/packages/openapi/openapi.yml b/packages/openapi/openapi.yml index fd5cb64741..d4ec33f1d7 100644 --- a/packages/openapi/openapi.yml +++ b/packages/openapi/openapi.yml @@ -76,7 +76,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -103,10 +102,16 @@ components: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -339,7 +344,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -1995,7 +1999,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -2022,10 +2025,16 @@ components: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -2196,7 +2205,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -2878,7 +2886,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -2905,10 +2912,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -3232,7 +3245,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -4285,7 +4297,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -5141,7 +5152,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -5168,10 +5178,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -5405,7 +5421,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -6813,13 +6828,6 @@ paths: propertyNames: type: string additionalProperties: {} - version: - type: integer - minimum: 1 - maximum: 9007199254740991 - description: Pin the link to a specific version of the license plan. Omitted, an - existing link keeps its pinned version and a new link - resolves to the latest. required: - license_plan_id description: Plans offered as assignable licenses under this plan. The full set @@ -6882,7 +6890,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -6909,10 +6916,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -7872,7 +7885,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -7899,10 +7911,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed + top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -8076,7 +8095,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -9184,7 +9202,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -9211,10 +9228,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed + top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -9388,7 +9412,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -10475,7 +10498,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -10502,10 +10524,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's + consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -10680,7 +10709,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -11016,6 +11044,7 @@ paths: @param allVersions - Apply the update diff to all versions of this plan. Mutually exclusive with disable_version. (optional) @param forceVersion - Force versioning even when no customers exist. Mutually exclusive with disable_version. (optional) @param updateVariantIds - Variant plan IDs to apply this update to. Empty or omitted means no propagation. (optional) + @param updateLicenseParents - Parent plan versions that should receive this license-plan update. (optional) @param variants - Additive variant updates for this base plan. Missing variants are created when name is provided. (optional) @param isDefault - Whether this is the org's default plan. Cannot be true on a variant. (optional) @@ -11594,13 +11623,6 @@ paths: propertyNames: type: string additionalProperties: {} - version: - type: integer - minimum: 1 - maximum: 9007199254740991 - description: Pin the link to a specific version of the license plan. Omitted, an - existing link keeps its pinned version and a new link - resolves to the latest. required: - license_plan_id description: Plans offered as assignable licenses under this plan. The full set @@ -11666,7 +11688,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -11693,10 +11714,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -11894,6 +11921,23 @@ paths: type: string description: Variant plan IDs to apply this update to. Empty or omitted means no propagation. + update_license_parents: + type: array + items: + type: object + properties: + plan_id: + type: string + minLength: 1 + pattern: ^[a-zA-Z0-9_-]+$ + version: + type: integer + minimum: 1 + maximum: 9007199254740991 + required: + - plan_id + - version + description: Parent plan versions that should receive this license-plan update. variants: type: array items: @@ -12249,7 +12293,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -12276,10 +12319,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed + top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -13232,7 +13282,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -13259,10 +13308,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed + top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -13436,7 +13492,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -15580,7 +15635,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -15607,10 +15661,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -16379,7 +16439,7 @@ paths: @example ```typescript // Schedule a transition from a trial plan to a paid plan - const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1784299196316,"plans":[{"planId":"trial_plan"}]},{"startsAt":1785508796316,"plans":[{"planId":"pro_plan"}]}] }); + const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1784744849888,"plans":[{"planId":"trial_plan"}]},{"startsAt":1785954449888,"plans":[{"planId":"pro_plan"}]}] }); ``` @param customerId - The ID of the customer to create the schedule for. @@ -17012,7 +17072,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -17039,10 +17098,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's + consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -17713,7 +17779,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -17740,10 +17805,17 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's + consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -19465,7 +19537,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -19492,10 +19563,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -22296,7 +22373,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -22323,10 +22399,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -23698,7 +23780,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -23725,10 +23806,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -26047,7 +26134,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -26074,10 +26160,16 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave + runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count + to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of @@ -28051,7 +28143,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -28826,7 +28917,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: @@ -30560,6 +30650,194 @@ paths: x-speakeasy-name-override: aggregate parameters: - *a1 + /v1/invoices.insert: + post: + operationId: insertInvoices + description: Inserts or updates up to 500 historical invoices without reading or + mutating the billing processor. + tags: + - invoices + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + invoices: + type: array + maxItems: 500 + items: + type: object + properties: + customer_id: + type: string + description: The customer this invoice belongs to. + plan_ids: + type: array + items: + type: string + default: [] + description: Plan IDs represented by this invoice. + stripe_id: + type: string + description: The processor's stable invoice ID. + processor_type: + enum: + - stripe + - revenuecat + type: string + default: stripe + description: The billing processor that owns this invoice. + status: + enum: + - draft + - open + - void + - paid + - uncollectible + type: string + description: The invoice status. + total: + type: number + description: The invoice total in major currency units. + amount_paid: + anyOf: + - type: number + - type: "null" + default: null + description: The amount paid in major currency units. + refunded_amount: + type: number + default: 0 + description: The refunded amount in major currency units. + currency: + type: string + description: The currency code. Defaults to the organization's default currency. + created_at: + type: number + description: The invoice creation timestamp in milliseconds. + hosted_invoice_url: + anyOf: + - type: string + - type: "null" + default: null + description: The hosted invoice URL, when available. + required: + - customer_id + - stripe_id + - status + - total + - created_at + description: Invoices to insert or update, in response order. + required: + - invoices + title: InsertInvoicesParams + examples: + - invoices: + - customer_id: cus_123 + plan_ids: + - pro + stripe_id: in_legacy_123 + processor_type: stripe + status: paid + total: 29.99 + amount_paid: 29.99 + refunded_amount: 0 + currency: usd + created_at: 1451606400000 + hosted_invoice_url: https://billing.example.com/invoices/legacy-123 + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + invoices: + type: array + items: + type: object + properties: + plan_ids: + type: array + items: + type: string + description: Array of plan IDs included in this invoice + stripe_id: + type: string + description: The Stripe invoice ID + processor_type: + enum: + - stripe + - revenuecat + type: string + default: stripe + description: The billing processor that owns this invoice. + status: + type: string + description: The status of the invoice + total: + type: number + description: The total amount of the invoice + currency: + type: string + description: The currency code for the invoice + created_at: + type: number + description: Timestamp when the invoice was created + hosted_invoice_url: + anyOf: + - type: string + - type: "null" + description: URL to the Stripe-hosted invoice page + id: + type: string + description: The Autumn invoice ID. + customer_id: + type: string + description: The customer this invoice belongs to. + amount_paid: + anyOf: + - type: number + - type: "null" + description: The amount paid in major currency units. + refunded_amount: + type: number + description: The refunded amount in major currency units. + required: + - plan_ids + - stripe_id + - status + - total + - currency + - created_at + - id + - customer_id + - amount_paid + - refunded_amount + description: Inserted or updated invoices in request order. + required: + - invoices + examples: + - invoices: + - id: inv_2b3c4d5e6f7g8h + customer_id: cus_123 + plan_ids: + - pro + stripe_id: in_legacy_123 + processor_type: stripe + status: paid + total: 29.99 + amount_paid: 29.99 + refunded_amount: 0 + currency: usd + created_at: 1451606400000 + hosted_invoice_url: https://billing.example.com/invoices/legacy-123 + x-speakeasy-name-override: insert + parameters: + - *a1 /v1/invoices.list: post: operationId: listInvoices diff --git a/packages/openapi/v2.3/contracts/index.ts b/packages/openapi/v2.3/contracts/index.ts index 8f8df033af..72c3319308 100644 --- a/packages/openapi/v2.3/contracts/index.ts +++ b/packages/openapi/v2.3/contracts/index.ts @@ -48,7 +48,10 @@ import { listFeaturesContract, updateFeatureContract, } from "./featuresContract.js"; -import { listInvoicesContract } from "./invoicesContract.js"; +import { + insertInvoicesContract, + listInvoicesContract, +} from "./invoicesContract.js"; import { keysMintContract, keysRefreshContract, @@ -132,6 +135,7 @@ export const v2_3ContractRouter = oc.router({ eventsAggregate: eventsAggregateContract, // Invoices + invoicesInsert: insertInvoicesContract, invoicesList: listInvoicesContract, // Licenses diff --git a/packages/openapi/v2.3/contracts/invoicesContract.ts b/packages/openapi/v2.3/contracts/invoicesContract.ts index 8d6ec86f25..9f6c723ee3 100644 --- a/packages/openapi/v2.3/contracts/invoicesContract.ts +++ b/packages/openapi/v2.3/contracts/invoicesContract.ts @@ -1,5 +1,7 @@ import { createCursorPaginatedResponseSchema } from "@api/common/cursorPaginationSchemas.js"; import { ApiListInvoiceV1Schema } from "@api/others/apiInvoice/apiListInvoiceV1.js"; +import { InsertInvoicesParamsSchema } from "@api/others/apiInvoice/insertInvoicesParams.js"; +import { InsertInvoicesResponseSchema } from "@api/others/apiInvoice/insertInvoicesResponse.js"; import { ListInvoicesParamsSchema } from "@api/others/apiInvoice/listInvoicesParams.js"; import { oc } from "@orpc/contract"; @@ -19,6 +21,70 @@ const LIST_INVOICE_EXAMPLE = { hosted_invoice_url: "https://invoice.stripe.com/i/acct_123/test_456", }; +export const insertInvoicesContract = oc + .route({ + method: "POST", + path: "/v1/invoices.insert", + operationId: "insertInvoices", + tags: ["invoices"], + description: + "Inserts or updates up to 500 historical invoices without reading or mutating the billing processor.", + spec: (spec) => ({ + ...spec, + "x-speakeasy-name-override": "insert", + }), + }) + .input( + InsertInvoicesParamsSchema.meta({ + title: "InsertInvoicesParams", + examples: [ + { + invoices: [ + { + customer_id: "cus_123", + plan_ids: ["pro"], + stripe_id: "in_legacy_123", + processor_type: "stripe", + status: "paid", + total: 29.99, + amount_paid: 29.99, + refunded_amount: 0, + currency: "usd", + created_at: 1451606400000, + hosted_invoice_url: + "https://billing.example.com/invoices/legacy-123", + }, + ], + }, + ], + }), + ) + .output( + InsertInvoicesResponseSchema.meta({ + examples: [ + { + invoices: [ + { + id: "inv_2b3c4d5e6f7g8h", + customer_id: "cus_123", + plan_ids: ["pro"], + stripe_id: "in_legacy_123", + processor_type: "stripe", + status: "paid", + total: 29.99, + amount_paid: 29.99, + refunded_amount: 0, + currency: "usd", + created_at: 1451606400000, + hosted_invoice_url: + "https://billing.example.com/invoices/legacy-123", + }, + ], + }, + ], + }), + ); + export const listInvoicesContract = oc .route({ method: "POST", diff --git a/packages/openapi/v2.3/openapi2.3.ts b/packages/openapi/v2.3/openapi2.3.ts index 44fc29f411..c0ca7c9ec7 100644 --- a/packages/openapi/v2.3/openapi2.3.ts +++ b/packages/openapi/v2.3/openapi2.3.ts @@ -14,10 +14,11 @@ import { CreateBalanceParamsV0Schema, CreateCustomerParamsV1Schema, CustomerDataSchema, - DfuFlashParamsSchema, CustomerExpandEnum, CustomerIdSchema, + DfuFlashParamsSchema, GetCustomerParamsV1Schema, + InsertInvoicesParamsSchema, LATEST_VERSION, PreviewUpdateSubscriptionResponseSchema, SetupPaymentParamsV1Schema, @@ -80,6 +81,7 @@ async function generateOpenApiDocument(): Promise> { registerInternalSchemas(CustomerDataSchema); registerInternalSchemas(ApiEventsListV2_3ParamsSchema); registerInternalSchemas(DfuFlashParamsSchema); + registerInternalSchemas(InsertInvoicesParamsSchema); const openApiDocument = (await generator.generate(v2_3ContractRouter, { info: { diff --git a/packages/sdk/.speakeasy/code-samples.overlay.yaml b/packages/sdk/.speakeasy/code-samples.overlay.yaml index b64d11641c..2ec40beef3 100644 --- a/packages/sdk/.speakeasy/code-samples.overlay.yaml +++ b/packages/sdk/.speakeasy/code-samples.overlay.yaml @@ -975,6 +975,42 @@ actions: console.log(result); } + run(); + - target: $["paths"]["/v1/invoices.insert"]["post"] + update: + x-codeSamples: + - lang: typescript + label: Typescript (SDK) + source: |- + import { Autumn } from "@useautumn/sdk"; + + const autumn = new Autumn({ + xApiVersion: "2.3.0", + secretKey: process.env["AUTUMN_SECRET_KEY"] ?? "", + }); + + async function run() { + const result = await autumn.invoices.insert({ + invoices: [ + { + customerId: "cus_123", + planIds: [ + "pro", + ], + stripeId: "in_legacy_123", + status: "paid", + total: 29.99, + amountPaid: 29.99, + currency: "usd", + createdAt: 1451606400000, + hostedInvoiceUrl: "https://billing.example.com/invoices/legacy-123", + }, + ], + }); + + console.log(result); + } + run(); - target: $["paths"]["/v1/invoices.list"]["post"] update: diff --git a/packages/sdk/.speakeasy/gen.lock b/packages/sdk/.speakeasy/gen.lock index f74d993715..34f153646c 100644 --- a/packages/sdk/.speakeasy/gen.lock +++ b/packages/sdk/.speakeasy/gen.lock @@ -1,16 +1,16 @@ lockVersion: 2.0.0 id: 7b300647-cd76-49e9-bf77-7d1bf5446d66 management: - docChecksum: a70ac88ba6c84057133a7a3fb23e9617 + docChecksum: 0ec6edf850540a933f6a79edc67f051b docVersion: 2.3.0 speakeasyVersion: 1.762.0 generationVersion: 2.882.0 releaseVersion: 0.10.18 configChecksum: 751d545c3d03661781a8ccf68e83a1b6 persistentEdits: - generation_id: 94bd460b-2068-4965-96e5-88c55dc68401 - pristine_commit_hash: 82e66ed6630907ba7231f410450571f0f187793b - pristine_tree_hash: 38d22d687bc8b0b93baed9f23be07ece27474e14 + generation_id: 0d6ea8f3-6cb4-4f5b-96b0-c7a3cddcdbce + pristine_commit_hash: 6e27cace85dacb2a8c4f37e6f8a89151e63daef3 + pristine_tree_hash: bd9de794c5b0304d44e2a94d235508aa94aacee7 features: typescript: additionalDependencies: 0.1.0 @@ -182,8 +182,8 @@ trackedFiles: pristine_git_object: 92d058f3586e4370fe64fe61ab605c68df98539b docs/models/attach-auto-topup.md: id: f199077483db - last_write_checksum: sha1:10edc41e0abf9ca2554a1c120eb6898e30316061 - pristine_git_object: 030383b8a163fa08385b084c5f4ec20b05f71665 + last_write_checksum: sha1:fbc53a41a4e322a795aa4d153fb974c082e4c28e + pristine_git_object: 539db8c0564f63fa0cb456425379543418fb4c49 docs/models/attach-base-price.md: id: f0987a200c0d last_write_checksum: sha1:0595dc42e9a153095daa7695ba01dd3f3335bf0a @@ -390,8 +390,8 @@ trackedFiles: pristine_git_object: 7a4cd9d8a1f0695b36c5fa314fda9de65365867e docs/models/attach-purchase-limit.md: id: af964d969c78 - last_write_checksum: sha1:2748c1abe894e046775c958fdf77cf44e736ec72 - pristine_git_object: 020e9a9a2c8009c372a2edd1f17af89606abefe0 + last_write_checksum: sha1:4341682d96cab477cc441ab297fdfb8d7cc89f85 + pristine_git_object: 28f19435079e671d0d35c31d5bf859a1dd8c5df8 docs/models/attach-redirect-mode.md: id: e3c87b6f489b last_write_checksum: sha1:822e38dbdafa354554ba2c73bededa06ec97c9a0 @@ -686,8 +686,8 @@ trackedFiles: pristine_git_object: 141e593599c2b1ba12c7149749a89d119e50c78a docs/models/billing-update-auto-topup.md: id: e8837f60d4f6 - last_write_checksum: sha1:d9bb81d15d5b56a6aa5530ed0618d4c227ae204e - pristine_git_object: 234dc11db069fe4fd0fa130bcf1dd0311caff2bb + last_write_checksum: sha1:0c81a621a14bd2358dd6c96b30f01ff7f4c8c3c9 + pristine_git_object: 5eea03ca83c09455b83e5b9821b89a1802fe34f3 docs/models/billing-update-base-price.md: id: 7a3d2f48b101 last_write_checksum: sha1:08dc49dbe42f6f1f8c7c6466d0a5fc38690ae1a6 @@ -866,8 +866,8 @@ trackedFiles: pristine_git_object: d7988287f09875b437308cc772473b23a1f64683 docs/models/billing-update-purchase-limit.md: id: 9b70580f4662 - last_write_checksum: sha1:9ffc9f99ab3e11294a5e0b42a7e3ada78c5668fc - pristine_git_object: 9fa0f09774d8f2b3646422500a15d9686e9077f4 + last_write_checksum: sha1:61bf8866a9a4aa69fee017fff8944a0769bcfb1d + pristine_git_object: 5ce193bc80b822b69d0484b8a4828f3d7c9cdd57 docs/models/billing-update-recalculate-balances.md: id: 8ead4615c410 last_write_checksum: sha1:faeed44cbca8f10ff5e55213ce959bba7da80fa5 @@ -1530,8 +1530,8 @@ trackedFiles: pristine_git_object: 11bf4a9d74e75a895e27489a55444fbe76d6488e docs/models/create-plan-auto-topup-request.md: id: 4220992b4af0 - last_write_checksum: sha1:58cd30fcf32cfeaf91057c9707e7f2a77c03268c - pristine_git_object: 54c7c845aa40965f29103a02c38fe1ce5d9c523a + last_write_checksum: sha1:15ea0b67158757f90aa76375cba09b18d10d609d + pristine_git_object: 94931759aecac4b2cf105fab14a19def4ab24eb2 docs/models/create-plan-auto-topup-response.md: id: 729f2f97759f last_write_checksum: sha1:acd8933ae36be68ed16aecfc56b152aac2c41ee9 @@ -1826,8 +1826,8 @@ trackedFiles: pristine_git_object: c4450261966af77f07f67824851ad2817e23ddde docs/models/create-plan-license.md: id: 3c6746410f2b - last_write_checksum: sha1:5d61d6a10e46fd0aacc2e17aea549134f324cb89 - pristine_git_object: 8f44d671f88b05b49687eac7c58052bc196cc8e8 + last_write_checksum: sha1:8d39c4cba301df2a2501b729b6a01d63724d5cd8 + pristine_git_object: 418e97658c18dd6351558cca9a1b8860e029a3dd docs/models/create-plan-limit-type-request-body.md: id: 2901a51053ea last_write_checksum: sha1:f16009db02d445e03c947fe39195ac489d6b5cd3 @@ -1914,8 +1914,8 @@ trackedFiles: pristine_git_object: ca1607781c10074ca0784aace3890e39717f358f docs/models/create-plan-purchase-limit-request.md: id: 89826591fd3e - last_write_checksum: sha1:e9f3a0ade7e33a18e5bb449f9a61e65abb85b403 - pristine_git_object: b6cc304a9fdfc89387beea6d0b6260ee1ce85317 + last_write_checksum: sha1:d14b15bb2754d227574f4231fda11e7e58032c43 + pristine_git_object: 08d1f7580b255d0b1d3c16e7b530ea3ad46a6af1 docs/models/create-plan-purchase-limit-response.md: id: 3f6d5600a425 last_write_checksum: sha1:5acb9b88e680b4e63afcf41eb9e87c56fef2eb38 @@ -1994,8 +1994,8 @@ trackedFiles: pristine_git_object: 3d56ab7c38e3f8bcdc2bcf381759c5d2e64e3401 docs/models/create-plan-variant-details-auto-topup.md: id: 47498d61f061 - last_write_checksum: sha1:5d733417db67c0c98c73c136b5443303397be8e3 - pristine_git_object: 25bc6d98737056b6975e2903a70fb960f6d12efb + last_write_checksum: sha1:db0bef949115236f0bc93d764eca609a0c8971f3 + pristine_git_object: a3645b9f82b290d0e2d6ce8460b5d0323e4a282c docs/models/create-plan-variant-details-billing-controls.md: id: da06df4a7ee6 last_write_checksum: sha1:805b0a46abbe809aa3bb58ca4d7e6f73aec45aa2 @@ -2046,8 +2046,8 @@ trackedFiles: pristine_git_object: b9d0c70eb15bea1107b9b0506452320526d16d9a docs/models/create-plan-variant-details-purchase-limit.md: id: ce32aa245fd1 - last_write_checksum: sha1:ce0ddd47a2b6fb219a6c90cde7261f62403c361d - pristine_git_object: a5c02353db718ddf182533ae18ef42651a5d17b7 + last_write_checksum: sha1:48f6098df0c72e426cd5d2d06f2bd8220defcac8 + pristine_git_object: 508d35dc99f7338479e5fc4642766959254c7ce0 docs/models/create-plan-variant-details-remove-item-billing-method.md: id: 2f9aabcb612f last_write_checksum: sha1:2be8fabed97356001281f1a8e52e677b099785ca @@ -2182,8 +2182,8 @@ trackedFiles: pristine_git_object: 1509adb1d6cae175888e8052ce9b3e90ab756c96 docs/models/create-schedule-auto-topup2.md: id: e67a209bf731 - last_write_checksum: sha1:8a6ca1c592f983b741f2e92d4df5c89afeee9904 - pristine_git_object: 063876dcb27b7737591e29d389f88214c8bc9862 + last_write_checksum: sha1:247111e9a86f895628e790595722fcb208dce4f8 + pristine_git_object: e86520ff22807d6cb3beb225f21e76d9821a78fd docs/models/create-schedule-base-price2.md: id: 56793b9353fd last_write_checksum: sha1:c345ab2b689f3c3460f361c8bfef6417530abecf @@ -2326,8 +2326,8 @@ trackedFiles: pristine_git_object: aee3872ba68699ba1279fdb7ceef92be1f839377 docs/models/create-schedule-purchase-limit2.md: id: 1216e233fbe7 - last_write_checksum: sha1:51e8d257d0dbbc34a087e987a5e72b4a70fbf44e - pristine_git_object: 9f7c2bc413d5b5aa11d1a07d39d004f45f43b111 + last_write_checksum: sha1:ce16339c8c5fa2610a4280bcea2b019200b0477a + pristine_git_object: 71e6710bf51218dd086ee0d6d02bbe97af6898d6 docs/models/create-schedule-redirect-mode.md: id: 4414e1893b11 last_write_checksum: sha1:a370027bd5e56f88c2223275e6131bfafacc6e44 @@ -2386,8 +2386,8 @@ trackedFiles: pristine_git_object: 240e81faccbe40b3977164f2a1df81eefa704cc5 docs/models/customer-data-auto-topup.md: id: a9028229550e - last_write_checksum: sha1:97f734c3650a194091ce892a213014c746b124ed - pristine_git_object: 656aaaab7f348684ec056ef2ef997dd1bf6f7b20 + last_write_checksum: sha1:462bd44b03e841f80699f9227557096123166a6f + pristine_git_object: c23728ad2717cf985aada4d24235993dc2f098aa docs/models/customer-data-billing-controls.md: id: ca09ea9a9d79 last_write_checksum: sha1:c5e4f1df7c531adf0d73ee3b777f17524f49623b @@ -2414,8 +2414,8 @@ trackedFiles: pristine_git_object: ddbb71ee6a29627e29d0d92640299bb671618c11 docs/models/customer-data-purchase-limit.md: id: 52a86c6092c4 - last_write_checksum: sha1:869703db26a5e1645575ed9992cfe8146c0925d7 - pristine_git_object: 4e7adc776f479f261795c577d468142a330355e9 + last_write_checksum: sha1:fae709851148f62f267f7dd06255675021c4ebe1 + pristine_git_object: 7e8df701ff9c297a16ad3dce4fcc0cf50d058f40 docs/models/customer-data-spend-limit.md: id: d6adb304f72d last_write_checksum: sha1:9732fa8bdcb1943408ed95c28b41fea452f22ab1 @@ -3118,8 +3118,8 @@ trackedFiles: pristine_git_object: 8f240bde6709a476ec744ba6216e1f0af24e113f docs/models/get-or-create-customer-auto-topup.md: id: "673761036094" - last_write_checksum: sha1:82829d5ba5755f620fd2e571c254d3ef6eadf82e - pristine_git_object: 8531f2bdc27ac9891b9bd369865f4cdbee359d73 + last_write_checksum: sha1:ecdd5b284bfbedb11b470d382497a8bb209d4237 + pristine_git_object: c31169ac6fb4b34d9e846684a0ff6c04d8d2cc6c docs/models/get-or-create-customer-billing-controls.md: id: e7ee10d9a50b last_write_checksum: sha1:ad80588d690231ca5f18a65682b1f1f2cbada54c @@ -3158,8 +3158,8 @@ trackedFiles: pristine_git_object: 95783157c590556a4a603f88b326ec681d72d338 docs/models/get-or-create-customer-purchase-limit.md: id: 409c503aeb70 - last_write_checksum: sha1:e3687c4701b8de15825ca18f6c2cbb9b53e5d8b4 - pristine_git_object: 7e2acbb5609dcc6998778dfcb03ab41fd0106700 + last_write_checksum: sha1:078ed99a39fbb1b5c47e40890eab2c6b5dbe0f2c + pristine_git_object: 1f3aea58b35d99647fa2891de13b62e27c62be62 docs/models/get-or-create-customer-spend-limit.md: id: 6bc905d17111 last_write_checksum: sha1:c618c77388ffe370ac6d0ff6a51eb28bb09be0d4 @@ -3430,8 +3430,8 @@ trackedFiles: pristine_git_object: 54129a2c2a0703561c4ab35f8e60ab2252994e87 docs/models/get-plan-variant-details-auto-topup.md: id: 96512e86d732 - last_write_checksum: sha1:089f2a2051bc43467294798229e53591429f1ffd - pristine_git_object: dcd2bc82519a674511ac31ba8430316e8dc5b520 + last_write_checksum: sha1:2f7908950021cbc569a30040d9bf233cb89fd537 + pristine_git_object: b83e1657a50e5d02c9ff8fc008cff330a75619db docs/models/get-plan-variant-details-billing-controls.md: id: d43998bdec25 last_write_checksum: sha1:3ad5bbbfcea35a7b55531167d9b443ef64c048b8 @@ -3470,8 +3470,8 @@ trackedFiles: pristine_git_object: 65ade8c40c69d89ed83c6d86e036418ef876f7f8 docs/models/get-plan-variant-details-purchase-limit.md: id: fa2a6849af3e - last_write_checksum: sha1:2308fd5570f9e1f105c4e9b182b2ee00d700c912 - pristine_git_object: dc18a58e553f0d0dfcd1682d6fc19a830dcad02a + last_write_checksum: sha1:05a769856eb8b6ef5b840ed0e45a6e92a2f6e4b0 + pristine_git_object: 5737c24d68c4c80403f06ebcba92b7ec2fddf5f6 docs/models/get-plan-variant-details-reset-interval.md: id: fd6c1816e3c3 last_write_checksum: sha1:49bff5ee3f007b97342f3fda25f68934badeff6e @@ -3600,6 +3600,34 @@ trackedFiles: id: d32fd48d0d1e last_write_checksum: sha1:1db7b73b664823c5938063172e43017b807eea84 pristine_git_object: c3892aa1ab8837c731f49e042711f10729d82df9 + docs/models/insert-invoices-globals.md: + id: e4793d06ea74 + last_write_checksum: sha1:c12f39ffe8f3577f1de4aa29ba78cee7eaeb6ca3 + pristine_git_object: 8be226f893bae107bd0010e2d6a89ae5384b0142 + docs/models/insert-invoices-invoice-response.md: + id: c72ba5ab432a + last_write_checksum: sha1:5533605aca672b470ab9c8275d12e3ce8380a635 + pristine_git_object: 6cae687c4df31a523a0744e6bd1a0a2acd32ffa0 + docs/models/insert-invoices-params.md: + id: 9312155ed5e1 + last_write_checksum: sha1:4e42196c3854b6bdaea7338e32f3abd9f07c04cd + pristine_git_object: d4ffadc60dfbf3be734090c1fd4bf678795b5398 + docs/models/insert-invoices-processor-type-request.md: + id: 59401b3a55ad + last_write_checksum: sha1:4591008c83eb0cd22818a05e6376fb325ad1f47c + pristine_git_object: 294d576f892a1dbac853307873ca66133ccdee68 + docs/models/insert-invoices-processor-type-response.md: + id: f62c7952ce8c + last_write_checksum: sha1:9899c179309466b2feac5c7c85bb11f874461280 + pristine_git_object: 5284be33c06bcfe75504e88a9ca7d970e92c0462 + docs/models/insert-invoices-response.md: + id: b3b879528460 + last_write_checksum: sha1:eec651926ff7e77f662dab455b916e02d2e50781 + pristine_git_object: b297e7e131b9e5182286416154a56968ddeae714 + docs/models/insert-invoices-status.md: + id: 5b172c93fb62 + last_write_checksum: sha1:03424c06abb1e77428c3f4a915c9c6a1fe589ab8 + pristine_git_object: 901151bdb3172d0427221d3d61f249f5ad666939 docs/models/intent.md: id: a4a6fa8818f7 last_write_checksum: sha1:39fa3a78bda572fa9b8a765966413b6d2b4436ea @@ -3612,6 +3640,10 @@ trackedFiles: id: ab31c20717b4 last_write_checksum: sha1:352ae4842b17c4e788dcab9ec3b739f84eed343f pristine_git_object: edb8c4e072206f2fbb44ed7435ffc97071b3c459 + docs/models/invoice-request-body.md: + id: d00e1fe176ed + last_write_checksum: sha1:738ee16658b9443ecc501dce96d67e8f033a3c03 + pristine_git_object: b40b5a52efb8af122d7fb792a1d9d14fe48e5614 docs/models/invoice.md: id: 18e2034f11ad last_write_checksum: sha1:7876f7e8c989534234e6b9dac8900a0e3d8952b5 @@ -4062,8 +4094,12 @@ trackedFiles: pristine_git_object: f7450741166428bc97e079c1e45588e9e488e243 docs/models/list-invoices-params.md: id: 9fbe6a2d604b - last_write_checksum: sha1:7ca491c3a0c8a4000b618c2c31ffa837214973bf - pristine_git_object: 873ed0f22aad154ce2b8191d4b18eddc548b8cff + last_write_checksum: sha1:f1df1165a6e03f5a2ebb77a2b10c52053931a88d + pristine_git_object: 5c2b2d060fba9db0f6e36c4db003b668b7cd6d25 + docs/models/list-invoices-processor-type-request.md: + id: 37ffbe3a119b + last_write_checksum: sha1:8db44e999028b94eb770b3fc76bc6f28746e0168 + pristine_git_object: 42d29ddb292b5b4460b5648a5364fe47c81917e0 docs/models/list-invoices-response.md: id: e981f0d9a6d3 last_write_checksum: sha1:da613f8a7d3dd645acc3e88dc007553a3349d7aa @@ -4326,8 +4362,8 @@ trackedFiles: pristine_git_object: 37f977d5325587be84a9196fad8a8b24422ccfc9 docs/models/list-plans-variant-details-auto-topup.md: id: 512e7d204755 - last_write_checksum: sha1:8c5b13e07a18f9ac18771394276561a3a74612ad - pristine_git_object: 95e55b8bd56f7690c8e6e105ea456d458abe6c8c + last_write_checksum: sha1:cebd75f0b0c6c7e8673baf9fe337b871a484887e + pristine_git_object: 144a60dcad2b909d6e372d2bc4e75bf874a25a40 docs/models/list-plans-variant-details-billing-controls.md: id: b01a238f2194 last_write_checksum: sha1:64620dd73d06f9d217ad0a223f0e31d7b07e0f92 @@ -4366,8 +4402,8 @@ trackedFiles: pristine_git_object: 9c8c5bed58c747077ae5cf8bd7a3a5522ef2b53d docs/models/list-plans-variant-details-purchase-limit.md: id: d834c9c2d7d1 - last_write_checksum: sha1:5d1b4f429f2fb17f519aa905fbb7f382a35f94f4 - pristine_git_object: dc942c9cd5f21bccc76a7912ca2ea91abc268bbc + last_write_checksum: sha1:ce8030c66c248627db6ac1cfb25f948349522f86 + pristine_git_object: f04ae05ae2841895044de6c17f0befcc69c93d3b docs/models/list-plans-variant-details-reset-interval.md: id: e6025a763aa3 last_write_checksum: sha1:ab1a97e311be254067fff8705cf18ec034d01851 @@ -4870,8 +4906,8 @@ trackedFiles: pristine_git_object: 91e2e12508c0f73d9ec8cc8e1f292281d21dd35d docs/models/plan-variant-details-auto-topup.md: id: 9b9cc6c8a012 - last_write_checksum: sha1:15f55a24c55a1b22cc8dc38799f45d9af36f0a64 - pristine_git_object: 331e4f9a3e023f287fc4e160d2744e4ecdab2744 + last_write_checksum: sha1:62bb552ec3c11666b22dfed9162505ab23435f3b + pristine_git_object: 6a41d1cc88a0c85923e2ed230d28374f987f1e10 docs/models/plan-variant-details-billing-controls.md: id: 0639706e276a last_write_checksum: sha1:ba4a907b80ee905296aa61db3185657d74fffa48 @@ -4902,8 +4938,8 @@ trackedFiles: pristine_git_object: e19fcc8797756448993708965cac3506a24b944c docs/models/plan-variant-details-purchase-limit.md: id: 18c5345423f1 - last_write_checksum: sha1:fbe31130ab79cfb6376b9b51db6b8adbe44d3fdf - pristine_git_object: 1876f167bcf926588693fb848c54e47dd258202a + last_write_checksum: sha1:6702981c6a15db0d78ebf13a847c6b26bde28d9a + pristine_git_object: 7bd6830e5a56cac53d706cd7d42480360075ac5b docs/models/plan-variant-details-reset-interval.md: id: d92854106bdd last_write_checksum: sha1:a4a371045792bb04378dbecbbc468f262320936d @@ -5026,8 +5062,8 @@ trackedFiles: pristine_git_object: 233439ea9e7483337c1974e355294de7350d5b24 docs/models/preview-attach-auto-topup.md: id: 5c1a9152aa0f - last_write_checksum: sha1:fd363a77a28d2fedf5f1a899f061197cc2361826 - pristine_git_object: f11fa75c5a1f0715c8c4b3699042f14a47e633f8 + last_write_checksum: sha1:68eadc96d212d46d7e0730016537f33b86c1106b + pristine_git_object: 7dec4bd69311d6154c4d689b07960f7965226a37 docs/models/preview-attach-base-price.md: id: c03a523d8cd0 last_write_checksum: sha1:431c21f19b6c6a8b14d349f53935611ab3324a01 @@ -5262,8 +5298,8 @@ trackedFiles: pristine_git_object: 6a1f268957b8dec1015680d5828dcfbc11429c4f docs/models/preview-attach-purchase-limit.md: id: d03ba8e9c95f - last_write_checksum: sha1:84100d7ea5105cb94f5756e2bd3dffcd2936e649 - pristine_git_object: 75b68d76f0bd1911e841ba1d905030276c7b7d09 + last_write_checksum: sha1:455252af9abd470578a74a9115fd43d06bbe1deb + pristine_git_object: 74c463a4c17c4ad61e6b6c2290cf70d9e7a30077 docs/models/preview-attach-redirect-mode.md: id: 09a67bc1b945 last_write_checksum: sha1:3af045689583aa733d59cda58dbe2f7dbb52ad2c @@ -5782,8 +5818,8 @@ trackedFiles: pristine_git_object: 2dfbb6d2767db7a27132c846b17b7dd83d30f668 docs/models/preview-update-auto-topup.md: id: ce3863fb653f - last_write_checksum: sha1:2b74bcaa771787388deee5c1e887a96428f826e7 - pristine_git_object: 3c12069fe7f79104540059d869d578e9fdfd166e + last_write_checksum: sha1:1a5821f0aa87bb8e89444144ed6f46184cab864f + pristine_git_object: 1fa274d7a1cc4cee500beb6ef20372c3d17d1d83 docs/models/preview-update-base-price.md: id: 2da5e78ec0e0 last_write_checksum: sha1:897365ce797986b84c8fc197e5173764ed8a7f8e @@ -6006,8 +6042,8 @@ trackedFiles: pristine_git_object: 44581c88aa8b9c135e1e9457d0095849de0e04e0 docs/models/preview-update-purchase-limit.md: id: 2425fb2db9d0 - last_write_checksum: sha1:ddf8b4e6dde28d414e952a380759dc9afa0e8d8c - pristine_git_object: f0c578ec22a4735516d432ace00e9769033cf0fa + last_write_checksum: sha1:824abd986be1d08bf257442efd3c291e15a1a22c + pristine_git_object: 731515178301229f3e7a9966599c0317693e9244 docs/models/preview-update-recalculate-balances.md: id: 8492e617571a last_write_checksum: sha1:b42eaaf40821a806f22d0a5be11a543d1ef12ce2 @@ -6160,10 +6196,6 @@ trackedFiles: id: 284eb48beca2 last_write_checksum: sha1:a7aff4d46925fac8f887760f5438bc72d9d9d1d5 pristine_git_object: a5910b07c02a166869181f5fd30a3192490c98dc - docs/models/processor-type-request.md: - id: 30645e14d67c - last_write_checksum: sha1:03eec6aa4d19c60ba0493f9ec76eed35b4f38761 - pristine_git_object: 4d6e5d310139fa843e271d7af0a83ac9a874746d docs/models/processor-type.md: id: 4e1ee9632454 last_write_checksum: sha1:e5c091efb3ac5d256119b589918fb9e3fd91b92f @@ -6390,8 +6422,8 @@ trackedFiles: pristine_git_object: 48d618e57811e63de01d8ee23b8926f7a6fd2b6a docs/models/setup-payment-auto-topup.md: id: 2439c659bd09 - last_write_checksum: sha1:56c371a48fe179270b5bb9f45538e32b8b7ff131 - pristine_git_object: 6ad9b3ec57408c2030e4e40938a004901fbab9f3 + last_write_checksum: sha1:50e815c0b5cad1afe068a412cc0c7d5c11dfe0d2 + pristine_git_object: 356f7afeddecdff8fc4de889f33df8f2b8c84696 docs/models/setup-payment-base-price.md: id: 7379e4922abd last_write_checksum: sha1:8bd14fbd0c7058d285aed7feb8105c672c071bf6 @@ -6566,8 +6598,8 @@ trackedFiles: pristine_git_object: 5e4238dc6d5861f0b1a387d29faf4a8ee4d0663b docs/models/setup-payment-purchase-limit.md: id: dbdefd4815a2 - last_write_checksum: sha1:636f2c215ba1dfb78ab8171d1a239956209d445f - pristine_git_object: 4e7e019e30b801b724068a6c4fcc04bb77a2ecee + last_write_checksum: sha1:5e226f710544fa34f1c74c68dd5f876f819f80ab + pristine_git_object: e0b4f75877f6eb45512c9c5f0bf5293849677512 docs/models/setup-payment-remove-item-billing-method.md: id: 71080c21cd33 last_write_checksum: sha1:784cbf9747dc8299617e342b91a9b75a392b4bd3 @@ -6882,8 +6914,8 @@ trackedFiles: pristine_git_object: 499e9e62b93f93e4774017a911c621b91407c3bb docs/models/update-customer-auto-topup-request.md: id: 9d975c2052ed - last_write_checksum: sha1:9c480fc958172d19f79d23c06eab743d9fadef0a - pristine_git_object: af2107e24d41ddaf7ee5d3b349fdf2fa84bbd5ce + last_write_checksum: sha1:7ce7ab5be0d39069778248aac2fa1e2b3d05c8e8 + pristine_git_object: cbd2e82202476708c61b3a97d1c81b632f2c77c3 docs/models/update-customer-auto-topup-response.md: id: 3c24a7d246cc last_write_checksum: sha1:335579c7f116e577885bb990a0fb822f23e36c19 @@ -6998,8 +7030,8 @@ trackedFiles: pristine_git_object: d8da2e158e5dd58bbce8592b7129ed52f11269d9 docs/models/update-customer-purchase-limit-request.md: id: ae71fddbf2a5 - last_write_checksum: sha1:8ad56d2a18ce89fd91461ffa51cf3aab0e3872c2 - pristine_git_object: 94023a7f19ac6c8e9bb228f623eee1ff95dd41d8 + last_write_checksum: sha1:190149ab65bade4cc03c17f7c2e6055c7fab8d3b + pristine_git_object: c712b48298e6f65ef95366d16c6b8f80efb327a1 docs/models/update-customer-purchase-limit-response1.md: id: 4706d17e1852 last_write_checksum: sha1:1a6e38a611106a213c9658d5461ba3f3c20d0d8b @@ -7320,6 +7352,10 @@ trackedFiles: id: 55ef8f61ed28 last_write_checksum: sha1:faf37d8d85056c22c1e7718c15ebce9706291ff5 pristine_git_object: 46197bb53415db585d6e501b766ff57c2affbf9f + docs/models/update-license-parent.md: + id: eb2ed547ea94 + last_write_checksum: sha1:f60a9fc1095516e85c356b12b7836c327859a64c + pristine_git_object: 0f86a4230aa8c556c15ec13e2a3eac1e3e55f8f7 docs/models/update-plan-additional-currency-request-body.md: id: bf2db8c4a85c last_write_checksum: sha1:c6607ea607db690c5e38270e22bcf994d6e17649 @@ -7334,8 +7370,8 @@ trackedFiles: pristine_git_object: e67655ce96d6c0b48a0a6930027a0394b816136d docs/models/update-plan-auto-topup-request.md: id: b29b46295c91 - last_write_checksum: sha1:0d3af0f345fffee42039e8f46512857dfb947e47 - pristine_git_object: 1e2dfa302a60d3f00c49ea7ab9e37726837a0300 + last_write_checksum: sha1:1aaf1abfae890a56b00e2d692422a43e2059de8b + pristine_git_object: 1e4060547b23bbf35b8d55b634fc77c381c59706 docs/models/update-plan-auto-topup-response.md: id: ff840e2867e3 last_write_checksum: sha1:33ad47af3f462bd8896bbb461e4be0e751c721f4 @@ -7638,8 +7674,8 @@ trackedFiles: pristine_git_object: e920c1ad84dce2c76a46dc349a9d9c0e66f32895 docs/models/update-plan-license.md: id: 84c810207333 - last_write_checksum: sha1:cf11ed775564e615747669956b5a3f1f124599ff - pristine_git_object: 767ecd125c52a841804d6491e56fd4eaad002dab + last_write_checksum: sha1:e09274ea745f33a941075af6e0bb36e4c5b16dc1 + pristine_git_object: 1a57ae64a161f3ad40845bf3e33f4b7f39fe091d docs/models/update-plan-limit-type-request-body.md: id: 2ad33ffd7761 last_write_checksum: sha1:a3878d58295eabb4e8d503a63ba3358351896047 @@ -7666,8 +7702,8 @@ trackedFiles: pristine_git_object: 637649d78c9e068c2842d5ca63e7d1428dcf8ab3 docs/models/update-plan-params.md: id: baf87a97d876 - last_write_checksum: sha1:cfdef069ed201df7926e7e198a60120104bb823c - pristine_git_object: 3b764129827dce8c36aec412a87bf4c19f71c02a + last_write_checksum: sha1:28f6824596dcec30aaf7ebfb80475f54e3d5d1af + pristine_git_object: cb6e116b5030ccefeb1c70c9aff38bd9a29494e5 docs/models/update-plan-plan-item-filter-response.md: id: df364ea1debe last_write_checksum: sha1:ed862f3e60fc738b64882e636f1a56c3c2686e99 @@ -7722,8 +7758,8 @@ trackedFiles: pristine_git_object: af094163ac68f309ea6f201e9b73c8e97259d375 docs/models/update-plan-purchase-limit-request.md: id: ba80a09b0b07 - last_write_checksum: sha1:63deae9440dec30b9e2c3ba6b5369c7a12c501c5 - pristine_git_object: 6232ea7446b1133e621cd61c6de147b97406270b + last_write_checksum: sha1:bd78d3837195e4870093e7031e65c2c6369c4907 + pristine_git_object: 544444c28b5f53ec32e7a637dcda40b4ecda2501 docs/models/update-plan-purchase-limit-response.md: id: b6e47d6bb20d last_write_checksum: sha1:cf7983205ce16ca91c186d7506f51e8bc7b51f40 @@ -7802,8 +7838,8 @@ trackedFiles: pristine_git_object: cd8e4d700c0809f7f4152d9e01370fe121e13072 docs/models/update-plan-variant-details-auto-topup.md: id: 3a5cb6662734 - last_write_checksum: sha1:cb74e759841471fab95c9fa3e4aa506589d634d6 - pristine_git_object: fdd3620d4b65bcecb178442b0dae36d38ef2c88d + last_write_checksum: sha1:c525e5ca3e1bbda34152af28e410938274f410e5 + pristine_git_object: 1fd5250157fd690031821a6b925ab03655ef5d54 docs/models/update-plan-variant-details-billing-controls.md: id: 6bd23551de5b last_write_checksum: sha1:0c1084e33f9e783d346f3fa72281e5ba5ed49cb2 @@ -7854,8 +7890,8 @@ trackedFiles: pristine_git_object: a366e8f356f83e8bc2614e3af5234f5bc41bd920 docs/models/update-plan-variant-details-purchase-limit.md: id: 5c81ee49b05f - last_write_checksum: sha1:451b8a6a1f4d2a666120f8c81d1a42977837bba5 - pristine_git_object: 1711bb5992e3635420d7e0b6d25c577ae781f824 + last_write_checksum: sha1:98e0e146548ef6f507e23a0e9219feaf7f11e6cf + pristine_git_object: 8430b42186b812cfb297f9b3ec2d529e16a349d2 docs/models/update-plan-variant-details-remove-item-billing-method.md: id: f4ef80c199d0 last_write_checksum: sha1:3d32672d4d9b687293d428b2c26526158e129521 @@ -7950,8 +7986,8 @@ trackedFiles: pristine_git_object: 5c01e5c61c113c0c64da177542b6c92b2eec5063 docs/models/variant-auto-topup.md: id: dd1ea9ccb7ca - last_write_checksum: sha1:0a9ae7dfb6a4c6f9f024d531a713c74fe0f8931e - pristine_git_object: 588a9c80ed3148d23c18f3d1f0ceba662d5f85e0 + last_write_checksum: sha1:cb4f58b70bdd86c098bf7c6836850335c6dc3af8 + pristine_git_object: d82955c68f3f6b2a11298e32a4f2393153d2946d docs/models/variant-base-price.md: id: dd39d4ab9863 last_write_checksum: sha1:4643589c5d598d1c6ae1f58a8ef4e96c41e3df28 @@ -8054,8 +8090,8 @@ trackedFiles: pristine_git_object: dab76692c62d7e6c36714388a117c3390a2efa0e docs/models/variant-purchase-limit.md: id: 10ee4817654f - last_write_checksum: sha1:664c1e99e70ab61e1260e870f7782ae1afe3ab8d - pristine_git_object: 4688cdcf3491d19a7607538a7b9e88c27f8220c9 + last_write_checksum: sha1:a7aaa37a255059a0c83ce6630dde006b9d06b735 + pristine_git_object: 8dee260dff48c7b586ac1dc8b07400322dca3d68 docs/models/variant-remove-item-billing-method.md: id: c1266e029660 last_write_checksum: sha1:dab8da784a21b3d6cffb3b896c91f5d2db9d32f5 @@ -8126,8 +8162,8 @@ trackedFiles: pristine_git_object: 0ebe5146cd24c153cb9b7655e4f502c09f7d4abd docs/sdks/billing/README.md: id: dc915331dd9d - last_write_checksum: sha1:03d7f6f6b024cc9793f78a19f0d526a8469896af - pristine_git_object: 9a752a4d17687faf85ba29f006aa5ee0b4c4a392 + last_write_checksum: sha1:3dea219afa1894bb7f76bbbc1d3a72dedf9e263c + pristine_git_object: c38269a07da01854067cc02172e21cb63aad9ab4 docs/sdks/customers/README.md: id: 9332759cffc2 last_write_checksum: sha1:9ef975ed7a810cead53f98391be01f8aafd67d7d @@ -8146,8 +8182,8 @@ trackedFiles: pristine_git_object: ac31687aa9e81d6776ab4933d0ad07afeff8a6d0 docs/sdks/invoices/README.md: id: a3f76a06eded - last_write_checksum: sha1:ae8edc2aba3941f6b2a609b54e5d9cd0eb63b66e - pristine_git_object: d9af94d7ea5628a335b6251038e06aa8d781fc01 + last_write_checksum: sha1:b6d6f20c67fa4f9fff5cf635c03b2fd6ca1106b1 + pristine_git_object: ed4a91386f5392a4cc99950a5324b60b1364b670 docs/sdks/keys/README.md: id: 9a567cd0daf2 last_write_checksum: sha1:26f46e8f7700547819773dd51af269dcb5e7445e @@ -8158,8 +8194,8 @@ trackedFiles: pristine_git_object: 4f442e8316cf5791b2b368511aefcb29e1231a89 docs/sdks/plans/README.md: id: 2d8c741fff57 - last_write_checksum: sha1:c4c55cea35447b0ba4d59ecc699017f7973a6dc1 - pristine_git_object: a2f4b86c794b3d65702940a04ae81952939b7e30 + last_write_checksum: sha1:6f6b6d68c6227ab99703c25e84902fd0f1393a70 + pristine_git_object: 58d7d4f5bc82ecbdc759145aa5e4f4f83ebd4e03 docs/sdks/platform/README.md: id: b66219e9cd4d last_write_checksum: sha1:2b78aeff4d3c1023b1c461eba75a15fe864c04f9 @@ -8230,8 +8266,8 @@ trackedFiles: pristine_git_object: c335cfcdd5ac114b1f647246236b69442ecbcdb2 src/funcs/billing-create-schedule.ts: id: fd662bfcdc10 - last_write_checksum: sha1:ceb4cbf644d160c4ac308d9e0fbd814acbd03ee6 - pristine_git_object: 7e2d5f8e1c2cce12270d45b6f09c451d01322f48 + last_write_checksum: sha1:07837e3de85df705abcfdaba608bb155e38d3979 + pristine_git_object: 4f6d0f58925596e2ad4f79562a59070e86b19d72 src/funcs/billing-import.ts: id: ff582ad4629f last_write_checksum: sha1:673851739184e659789772519b9bc01485a21d79 @@ -8344,6 +8380,10 @@ trackedFiles: id: d2de3d7decac last_write_checksum: sha1:ab1ff29e5b73356e4a0fee309aeb199570353edf pristine_git_object: 0e8b430aae0d2ff7f1f12c3b0d08b86e70dc814a + src/funcs/invoices-insert.ts: + id: 9cf6fe8e5468 + last_write_checksum: sha1:6fd8533b062c61d82c292e6be77cbf8b23676877 + pristine_git_object: a62c9bd95aa6ecef2ff9cef8923bc4d0120fd5aa src/funcs/invoices-list.ts: id: 78f1278b5014 last_write_checksum: sha1:bc7afae26b246e0c1b0c4c6d2edda8ccb8095345 @@ -8386,8 +8426,8 @@ trackedFiles: pristine_git_object: 8fa31bd78e12c73e1bef0dbbf654d44f9aa2ffe3 src/funcs/plans-update.ts: id: 86e469e08973 - last_write_checksum: sha1:11d8fac066cbe767a85a3011b595fc2810b6837d - pristine_git_object: e710f9e979756058d6cf0e036fb3232e5cf59ac0 + last_write_checksum: sha1:88f88761210e842454d0410d5d43e707ee731e15 + pristine_git_object: e979aed821b2a64bbb18e831876e556d97470c86 src/funcs/platform-get-revenue-cat-keys.ts: id: 3f6df7d12152 last_write_checksum: sha1:ea7b7e7d13cec4a7dc9dfb20431bc67e91f6efb6 @@ -8514,8 +8554,8 @@ trackedFiles: pristine_git_object: 3675cd4e8ad374d6b2865e2160e6ab8a606943c2 src/models/attach-op.ts: id: 83ed65c26ab4 - last_write_checksum: sha1:1c7c242660dcd37fc4d0bbcb8458bfeb89abe55e - pristine_git_object: 27af21b0c93c2f56d5c0ce9d973f7db9acc54107 + last_write_checksum: sha1:ff0a94e9f6c29fd71703a9a455d3227009eb85b0 + pristine_git_object: 932eab043933c084dab19dd83462f0ac1dec8f28 src/models/autumn-default-error.ts: id: 2528aa7886eb last_write_checksum: sha1:4cce18f91be3262ada7d11dcd6326544e2341b58 @@ -8534,8 +8574,8 @@ trackedFiles: pristine_git_object: 16f66886fcadb22a9dc6d44137aea76959579d5c src/models/billing-update-op.ts: id: e7371769c7ca - last_write_checksum: sha1:49713a284065b0eb76345d85048833cf3668aa53 - pristine_git_object: 4a3ed6d731d884eb263c2ad8bf0acedbc4ca4d94 + last_write_checksum: sha1:4a82130233bdcdab80827abf46871b31c111afe9 + pristine_git_object: 2566f4ee4305f072ac239321b073f4cb9edb7827 src/models/check-op.ts: id: 42085bda016a last_write_checksum: sha1:b9a254aa82a4080ace1471a3241af7cef8954af8 @@ -8554,20 +8594,20 @@ trackedFiles: pristine_git_object: cc88593cb431fd33f22679a5eabae1eb9705c384 src/models/create-plan-op.ts: id: e094d152f358 - last_write_checksum: sha1:cbe3338bb5882342e72c70d11e959c7d64f53226 - pristine_git_object: dec4d66470226520d29fb6419279174bb030a0b5 + last_write_checksum: sha1:8a3258ec202c073a718d8cbf87196953c4ede49e + pristine_git_object: 24f80cae278bf0095490e85766fd387f22dcc6df src/models/create-referral-code-op.ts: id: 745cd70e7a69 last_write_checksum: sha1:01ce64d29c3bd84e0c9bf1e6a979e7e493f10d67 pristine_git_object: d979198ac227f8e5731e5ca5e2e34b55d88da348 src/models/create-schedule-op.ts: id: 68442c0abd75 - last_write_checksum: sha1:4bfd2bebe7b862aa3f899e5f927a986a61ba34cd - pristine_git_object: 60019d0e9978f44819ebc48da8beb2aebde07e33 + last_write_checksum: sha1:fa8e7d5c19015da04eeebca7c631b09dfa9f0186 + pristine_git_object: ed42c4b86bb279cad475376fdfe03bd30b245bc4 src/models/customer-data.ts: id: 04dac7ee392e - last_write_checksum: sha1:1b30a1e254d56693f3284443be6f349ffa4ec484 - pristine_git_object: d7b53b00bc0218a248e5e7496d53e9558ce6d04c + last_write_checksum: sha1:39275a61f77a7323d604fc327f515b13525e5f8d + pristine_git_object: 85e02d59cd1a0382adab8093815facb8b170eb5e src/models/customer-expand.ts: id: 0792f23b0bc0 last_write_checksum: sha1:0c9f3e2b934fcf8371c490ee4ca8cb9450c8db66 @@ -8614,12 +8654,12 @@ trackedFiles: pristine_git_object: 0097008c9b033d8069530b1b473a5a6f890b002e src/models/get-or-create-customer-op.ts: id: 46f8f65a57f2 - last_write_checksum: sha1:e102121fa894e6394082fba700f095de7ee70fe4 - pristine_git_object: 8bd7e9a076e3e57044bcb50baff6b24db7b503d2 + last_write_checksum: sha1:b9082e3198c9ceb5e970247d156ca7367f83f3c4 + pristine_git_object: de248bb3223837499ef0aad45ec21ecbf0d3a9f8 src/models/get-plan-op.ts: id: 91c8f8dda7c8 - last_write_checksum: sha1:91ff2a679af58b8ea4d5955f7936c8b32e076eb6 - pristine_git_object: deed3d3a3234fea40e21a78a4f4342ae33d4dc47 + last_write_checksum: sha1:21dc123819b9a7128a9e88c93d876bef747dcb92 + pristine_git_object: bf5e1279f338aac5b7e9ee77744f4b1ac51e7dbe src/models/get-revenue-cat-keys-op.ts: id: 2d8e9e87f071 last_write_checksum: sha1:e1c1f2eaf75a3db81b4800c63cbe2ea6e5b1fa56 @@ -8634,8 +8674,12 @@ trackedFiles: pristine_git_object: bd7047c94965f6154cbf8657c5a6a9ddabdf174d src/models/index.ts: id: f93644b0f37e - last_write_checksum: sha1:b5ce3e2b74d073f57c71d0c1f8c77087fb0bd3bf - pristine_git_object: 108897300b1dd238af6135185a1afbe30a8f966b + last_write_checksum: sha1:7a0af0ca2ac5d11e3c9c62b9ecf384c1ce609985 + pristine_git_object: c38815d4e216d9748638c1f2cff855d1917499ce + src/models/insert-invoices-op.ts: + id: d5b32875066f + last_write_checksum: sha1:414244eb6712967c72c16c4a656fe4688a759bc3 + pristine_git_object: b48d34e3cc1ff2708d3fcd435a4c30fea4eabbd8 src/models/link-revenue-cat-op.ts: id: 6cc62c90b574 last_write_checksum: sha1:a2f11a5efb037c6c640da26f07b2a407b7dfffad @@ -8658,12 +8702,12 @@ trackedFiles: pristine_git_object: cc0e37a190877dc4b5c825bf4c20b40fd7600211 src/models/list-invoices-op.ts: id: 475d8df302da - last_write_checksum: sha1:50e321760b823efb3204bcbd753f08a82a10c147 - pristine_git_object: f071ebd0f9b72b3e6ee86945969c5cb0e0a465ad + last_write_checksum: sha1:61e132b865f946013cdfd4b74023f08d7314b9eb + pristine_git_object: 006dc30d4ca20cfa16954578ac6dbf7b93f912e4 src/models/list-plans-op.ts: id: 513cde894485 - last_write_checksum: sha1:66917a089b45523f8fc4bab66dc13efe97a72513 - pristine_git_object: 78221e2253ad64bd0aeaab5655bb8101cb006b43 + last_write_checksum: sha1:435357f35e627b22f125798e227c3840e289cdab + pristine_git_object: 41c54675daefa730285d1b30c36d8597dc62e3b8 src/models/list-rewards-op.ts: id: 1fa339fa50fb last_write_checksum: sha1:281da10de3dd1399ed63a3910db0cae7bb5aa7d9 @@ -8686,12 +8730,12 @@ trackedFiles: pristine_git_object: 4a9318890026bc6e67a8a5b65dc596ae7f25f855 src/models/plan.ts: id: 9e9698a64fe7 - last_write_checksum: sha1:916b9112944aeb7b21e0b452dfbde12ac4727f0d - pristine_git_object: 861af25dede9ad35d72ab1c3e9f81c17e9f3dad7 + last_write_checksum: sha1:6e30f35b9f788e35b6df9b1dea708764b5f79357 + pristine_git_object: 178af66132697f9fb1810a8e7c0d121b2e106122 src/models/preview-attach-op.ts: id: 3efc6e3443a7 - last_write_checksum: sha1:7d10d27a284e933c4ee3f660d30835f4d9abf544 - pristine_git_object: 23b0f12466d2007bc84d32f2fab2227545905d4e + last_write_checksum: sha1:e6f6846714c4b38219e00bcd4558ecd749a12f45 + pristine_git_object: b395a92c8537426f403b1ead01b44cf423e29ed5 src/models/preview-multi-attach-op.ts: id: e4847dc281a6 last_write_checksum: sha1:89ea23151299f10f8b45aaef20e6297145653422 @@ -8702,8 +8746,8 @@ trackedFiles: pristine_git_object: 8cc0bd0a02d8c594be41c3cda6e0c88e69428ef1 src/models/preview-update-op.ts: id: fcbbbf3b22ac - last_write_checksum: sha1:0a0e87f79cdb66fdba29d99120ffe2dfe05196a8 - pristine_git_object: 6e2a2d1cfbf2c5849117db14c73b786fec3c9bc2 + last_write_checksum: sha1:0223f3a80876635299e21c8f008f4001a6e89304 + pristine_git_object: 2ddf0ba99e7f88a01a3c4e4fcc2d138e393cb33c src/models/redeem-referral-code-op.ts: id: 511bf73dc4c6 last_write_checksum: sha1:9ab6622018c82175ea98d2b26eadb4abf08f441a @@ -8738,8 +8782,8 @@ trackedFiles: pristine_git_object: 3774cc1e9bbb80ac592990aa86f8d4a38ee51f29 src/models/setup-payment-op.ts: id: 0e97e999ff3c - last_write_checksum: sha1:9208206c4bdbc1c944ceafbf5251d0e508270f0a - pristine_git_object: d806443282d3055ad5d25069b476396fddbd1a40 + last_write_checksum: sha1:57b8367c897264b7103a54a613f8950a0298ce78 + pristine_git_object: 65bab2b342c53a4230799f5137e4562d7ae00a40 src/models/sync-revenue-cat-op.ts: id: bf3c25067f7c last_write_checksum: sha1:a5992af2f44badc20be2d55c8091ed4c22dfc903 @@ -8758,8 +8802,8 @@ trackedFiles: pristine_git_object: 8c47bb5b8cf5546e12fffaddcab36022c1f2ea4a src/models/update-customer-op.ts: id: 5d226d30d8e4 - last_write_checksum: sha1:0f000a49837251f60bd105dccea642fdd0f1c8c4 - pristine_git_object: 98437d4b30bbe7b25e8eebe2565d9c97425cf5d4 + last_write_checksum: sha1:c982aacf8aaf8a25483df2073a757daa7522c67f + pristine_git_object: 2ea2f18148ef688db524c70fa3a155b092b7365e src/models/update-entity-op.ts: id: c3fdb6479f02 last_write_checksum: sha1:0b11754f6c14f9d5c4c8c688cff3846dd3571782 @@ -8770,16 +8814,16 @@ trackedFiles: pristine_git_object: 1564c4312ff7944eaaaa5b8e8a80d4877b97f134 src/models/update-plan-op.ts: id: 54b4f842d3b2 - last_write_checksum: sha1:dffac6acd6d5e84891606aec0e3fd2bfd2eb9744 - pristine_git_object: 0f847edb903062ee025a00d0c1df0ca3dccad6ae + last_write_checksum: sha1:20d43f8b0c1f0d28b22a5485a40916480f26546b + pristine_git_object: 6f2a857f17ed2e6115c4031a15b3a603b266326c src/sdk/balances.ts: id: 9ad229cb9d64 last_write_checksum: sha1:3e195bbaeea3a9bc5afd4095d92949f3cad0956e pristine_git_object: 571de419ea3321d79acec4bddbb46b1580007115 src/sdk/billing.ts: id: 10905058c4ad - last_write_checksum: sha1:1991c2717459c706cd60060768d70f8bfa725e53 - pristine_git_object: ded8bbc8c8c9f94ed0baa4590024b79735af6e69 + last_write_checksum: sha1:49f676179292a5de7b824cecbb70ee967f6f905e + pristine_git_object: 8bf3abf77f0cc26be6779670ac2e9541514a826a src/sdk/customers.ts: id: d33e193e0c00 last_write_checksum: sha1:9363c51bdf5a65a291f6ab33d70cf3742f5d3bbe @@ -8802,8 +8846,8 @@ trackedFiles: pristine_git_object: ecac2264817bb369ff2dbf0f0e9029807e67ff77 src/sdk/invoices.ts: id: b0baec52de2f - last_write_checksum: sha1:f1c2c93e37a9a017fbfa02373075c99d177968ea - pristine_git_object: 76d65ab90a24eb81e1025ab723ecf147ca5ea18c + last_write_checksum: sha1:94f7f2bba76946238ebfeafdf61cb431df265f35 + pristine_git_object: 2bc21cdbfb6c3a37f12243d8c6ebbeb080619b31 src/sdk/keys.ts: id: 474a4602e64e last_write_checksum: sha1:2e8e726339cd9e49bb9af908b9047565150e7483 @@ -8814,8 +8858,8 @@ trackedFiles: pristine_git_object: dd751dbd93b02d1edf3fa855efe47de7755d6e67 src/sdk/plans.ts: id: c0cb8188cdc1 - last_write_checksum: sha1:3b5ef2e13d353bda38d8758d382bb84dec18f45f - pristine_git_object: b9f2d8052b8d7df2c2408069c3beed3331bae690 + last_write_checksum: sha1:0c41ae6d16a32681c06a1f85fd8c7acd61514df2 + pristine_git_object: 2c0a568fa783fb129a966c78307cc2cc92ff4ff8 src/sdk/platform.ts: id: 86d659f7229d last_write_checksum: sha1:831299efdeefe09a98910994e31c0ff992f3915f @@ -9916,4 +9960,14 @@ examples: responses: "200": application/json: {"list": [{"license_plan_id": "seat_plan", "parent_plan_id": "pro_plan", "license_plan_name": "Seat", "granted": 10, "usage": 3, "remaining": 7, "paid_quantity": 5}]} + insertInvoices: + speakeasy-default-insert-invoices: + parameters: + header: + x-api-version: "2.3.0" + requestBody: + application/json: {"invoices": [{"customer_id": "cus_123", "plan_ids": ["pro"], "stripe_id": "in_legacy_123", "processor_type": "stripe", "status": "paid", "total": 29.99, "amount_paid": 29.99, "refunded_amount": 0, "currency": "usd", "created_at": 1451606400000, "hosted_invoice_url": "https://billing.example.com/invoices/legacy-123"}]} + responses: + "200": + application/json: {"invoices": [{"plan_ids": ["pro"], "stripe_id": "in_legacy_123", "processor_type": "stripe", "status": "paid", "total": 29.99, "currency": "usd", "created_at": 1451606400000, "hosted_invoice_url": "https://billing.example.com/invoices/legacy-123", "id": "inv_2b3c4d5e6f7g8h", "customer_id": "cus_123", "amount_paid": 29.99, "refunded_amount": 0}]} examplesVersion: 1.0.2 diff --git a/packages/sdk/.speakeasy/out.openapi.yaml b/packages/sdk/.speakeasy/out.openapi.yaml index e4e0df1836..ce703e95de 100644 --- a/packages/sdk/.speakeasy/out.openapi.yaml +++ b/packages/sdk/.speakeasy/out.openapi.yaml @@ -74,7 +74,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -100,10 +99,14 @@ components: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -322,7 +325,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -1865,7 +1867,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -1891,10 +1892,14 @@ components: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -2048,7 +2053,6 @@ components: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -2684,7 +2688,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -2710,10 +2713,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -3011,7 +3018,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -4001,7 +4007,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -4790,7 +4795,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -4816,10 +4820,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -5036,7 +5044,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -6336,11 +6343,6 @@ paths: propertyNames: type: string additionalProperties: {} - version: - type: integer - minimum: 1 - maximum: 9.007199254740991e+15 - description: Pin the link to a specific version of the license plan. Omitted, an existing link keeps its pinned version and a new link resolves to the latest. required: - license_plan_id description: Plans offered as assignable licenses under this plan. The full set replaces existing links. @@ -6396,7 +6398,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -6422,10 +6423,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -7297,7 +7302,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -7323,10 +7327,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -7480,7 +7488,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -8491,7 +8498,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -8517,10 +8523,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -8674,7 +8684,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -9655,7 +9664,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -9681,10 +9689,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -9838,7 +9850,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -10152,6 +10163,7 @@ paths: @param allVersions - Apply the update diff to all versions of this plan. Mutually exclusive with disable_version. (optional) @param forceVersion - Force versioning even when no customers exist. Mutually exclusive with disable_version. (optional) @param updateVariantIds - Variant plan IDs to apply this update to. Empty or omitted means no propagation. (optional) + @param updateLicenseParents - Parent plan versions that should receive this license-plan update. (optional) @param variants - Additive variant updates for this base plan. Missing variants are created when name is provided. (optional) @param isDefault - Whether this is the org's default plan. Cannot be true on a variant. (optional) @@ -10681,11 +10693,6 @@ paths: propertyNames: type: string additionalProperties: {} - version: - type: integer - minimum: 1 - maximum: 9.007199254740991e+15 - description: Pin the link to a specific version of the license plan. Omitted, an existing link keeps its pinned version and a new link resolves to the latest. required: - license_plan_id description: Plans offered as assignable licenses under this plan. The full set replaces existing links. @@ -10745,7 +10752,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -10771,10 +10777,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -10951,6 +10961,23 @@ paths: items: type: string description: Variant plan IDs to apply this update to. Empty or omitted means no propagation. + update_license_parents: + type: array + items: + type: object + properties: + plan_id: + type: string + minLength: 1 + pattern: ^[a-zA-Z0-9_-]+$ + version: + type: integer + minimum: 1 + maximum: 9.007199254740991e+15 + required: + - plan_id + - version + description: Parent plan versions that should receive this license-plan update. variants: type: array items: @@ -11273,7 +11300,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -11299,10 +11325,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -12162,7 +12192,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -12188,10 +12217,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -12345,7 +12378,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -14252,7 +14284,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -14278,10 +14309,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -14951,7 +14986,7 @@ paths: @example ```typescript // Schedule a transition from a trial plan to a paid plan - const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1784299196316,"plans":[{"planId":"trial_plan"}]},{"startsAt":1785508796316,"plans":[{"planId":"pro_plan"}]}] }); + const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1784744849888,"plans":[{"planId":"trial_plan"}]},{"startsAt":1785954449888,"plans":[{"planId":"pro_plan"}]}] }); ``` @param customerId - The ID of the customer to create the schedule for. @@ -15515,7 +15550,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -15541,10 +15575,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -16142,7 +16180,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -16168,10 +16205,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -17699,7 +17740,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -17725,10 +17765,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -20237,7 +20281,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -20263,10 +20306,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -21471,7 +21518,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -21497,10 +21543,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -23617,7 +23667,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -23643,10 +23692,14 @@ paths: type: number minimum: 1 description: Maximum number of auto top-ups allowed within the interval. + count: + type: number + minimum: 0 + description: Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. required: - interval - limit - description: Optional rate limit to cap how often auto top-ups occur. + description: Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. invoice_mode: type: boolean description: When true, auto top-up creates a send_invoice invoice instead of auto-charging. @@ -25416,7 +25469,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -26128,7 +26180,6 @@ paths: description: Whether auto top-up is enabled. threshold: type: number - minimum: 0 description: When the balance drops below this threshold, an auto top-up will be purchased. quantity: type: number @@ -27655,6 +27706,193 @@ paths: x-speakeasy-name-override: aggregate parameters: - *a1 + /v1/invoices.insert: + post: + operationId: insertInvoices + description: Inserts or updates up to 500 historical invoices without reading or mutating the billing processor. + tags: + - invoices + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + invoices: + type: array + maxItems: 500 + items: + type: object + properties: + customer_id: + type: string + description: The customer this invoice belongs to. + plan_ids: + type: array + items: + type: string + default: [] + description: Plan IDs represented by this invoice. + stripe_id: + type: string + description: The processor's stable invoice ID. + processor_type: + enum: + - stripe + - revenuecat + type: string + default: stripe + description: The billing processor that owns this invoice. + status: + enum: + - draft + - open + - void + - paid + - uncollectible + type: string + description: The invoice status. + total: + type: number + description: The invoice total in major currency units. + amount_paid: + anyOf: + - type: number + - type: "null" + default: null + description: The amount paid in major currency units. + refunded_amount: + type: number + default: 0 + description: The refunded amount in major currency units. + currency: + type: string + description: The currency code. Defaults to the organization's default currency. + created_at: + type: number + description: The invoice creation timestamp in milliseconds. + hosted_invoice_url: + anyOf: + - type: string + - type: "null" + default: null + description: The hosted invoice URL, when available. + required: + - customer_id + - stripe_id + - status + - total + - created_at + description: Invoices to insert or update, in response order. + required: + - invoices + title: InsertInvoicesParams + examples: + - invoices: + - customer_id: cus_123 + plan_ids: + - pro + stripe_id: in_legacy_123 + processor_type: stripe + status: paid + total: 29.99 + amount_paid: 29.99 + refunded_amount: 0 + currency: usd + created_at: 1451606400000 + hosted_invoice_url: https://billing.example.com/invoices/legacy-123 + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + invoices: + type: array + items: + type: object + properties: + plan_ids: + type: array + items: + type: string + description: Array of plan IDs included in this invoice + stripe_id: + type: string + description: The Stripe invoice ID + processor_type: + enum: + - stripe + - revenuecat + type: string + default: stripe + description: The billing processor that owns this invoice. + status: + type: string + description: The status of the invoice + total: + type: number + description: The total amount of the invoice + currency: + type: string + description: The currency code for the invoice + created_at: + type: number + description: Timestamp when the invoice was created + hosted_invoice_url: + anyOf: + - type: string + - type: "null" + description: URL to the Stripe-hosted invoice page + id: + type: string + description: The Autumn invoice ID. + customer_id: + type: string + description: The customer this invoice belongs to. + amount_paid: + anyOf: + - type: number + - type: "null" + description: The amount paid in major currency units. + refunded_amount: + type: number + description: The refunded amount in major currency units. + required: + - plan_ids + - stripe_id + - status + - total + - currency + - created_at + - id + - customer_id + - amount_paid + - refunded_amount + description: Inserted or updated invoices in request order. + required: + - invoices + examples: + - invoices: + - id: inv_2b3c4d5e6f7g8h + customer_id: cus_123 + plan_ids: + - pro + stripe_id: in_legacy_123 + processor_type: stripe + status: paid + total: 29.99 + amount_paid: 29.99 + refunded_amount: 0 + currency: usd + created_at: 1451606400000 + hosted_invoice_url: https://billing.example.com/invoices/legacy-123 + x-speakeasy-name-override: insert + parameters: + - *a1 /v1/invoices.list: post: operationId: listInvoices diff --git a/packages/sdk/.speakeasy/workflow.lock b/packages/sdk/.speakeasy/workflow.lock index 0481d2d3bb..188713bfc4 100644 --- a/packages/sdk/.speakeasy/workflow.lock +++ b/packages/sdk/.speakeasy/workflow.lock @@ -2,8 +2,8 @@ speakeasyVersion: 1.762.0 sources: Autumn API: sourceNamespace: autumn-api - sourceRevisionDigest: sha256:efac7660d9aca386df219c5028dccc61518f8e3a18e6c99ab75367153c0ba9c3 - sourceBlobDigest: sha256:d4f45ab94537037f30134394639439fc78723c17fdc138cfa8419ffae7070f5e + sourceRevisionDigest: sha256:55050d94598a33577d6469c69e6d7ce6791e2726c5396aad5be09c91f16fd7c2 + sourceBlobDigest: sha256:6257e01399e67c966be550f74194330b0ca34153dbbdb7fb17c53b7036250069 tags: - latest - 2.3.0 @@ -18,10 +18,10 @@ targets: autumn: source: Autumn API sourceNamespace: autumn-api - sourceRevisionDigest: sha256:efac7660d9aca386df219c5028dccc61518f8e3a18e6c99ab75367153c0ba9c3 - sourceBlobDigest: sha256:d4f45ab94537037f30134394639439fc78723c17fdc138cfa8419ffae7070f5e + sourceRevisionDigest: sha256:55050d94598a33577d6469c69e6d7ce6791e2726c5396aad5be09c91f16fd7c2 + sourceBlobDigest: sha256:6257e01399e67c966be550f74194330b0ca34153dbbdb7fb17c53b7036250069 codeSamplesNamespace: autumn-api-typescript-code-samples - codeSamplesRevisionDigest: sha256:dbcdc059964564ceec7796292caeaae003af30ed17ef62a575d2f8aa2186291e + codeSamplesRevisionDigest: sha256:f028d653180d5385753af8453e507b8eece20d519f067438c45bbb88025674ab autumn-python: source: Autumn API Stripped sourceNamespace: autumn-api-stripped diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 04d6302478..e0357fdade 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -313,7 +313,7 @@ Use this endpoint to schedule future plan changes (e.g. switch from a trial plan @example ```typescript // Schedule a transition from a trial plan to a paid plan -const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1784299196316,"plans":[{"planId":"trial_plan"}]},{"startsAt":1785508796316,"plans":[{"planId":"pro_plan"}]}] }); +const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1784744849888,"plans":[{"planId":"trial_plan"}]},{"startsAt":1785954449888,"plans":[{"planId":"pro_plan"}]}] }); ``` @param customerId - The ID of the customer to create the schedule for. @@ -793,6 +793,7 @@ const response = await client.features.delete({ featureId: "old-feature" }); ### [Invoices](docs/sdks/invoices/README.md) +* [insert](docs/sdks/invoices/README.md#insert) - Inserts or updates up to 500 historical invoices without reading or mutating the billing processor. * [list](docs/sdks/invoices/README.md#list) - Lists invoices with cursor pagination and optional filters (customer, entity, status, processor). Pass `start_cursor: ""` (or omit) for the first page; use `next_cursor` from a prior response for subsequent pages. ### [Keys](docs/sdks/keys/README.md) @@ -913,7 +914,7 @@ Use this endpoint to schedule future plan changes (e.g. switch from a trial plan @example ```typescript // Schedule a transition from a trial plan to a paid plan -const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1784299196316,"plans":[{"planId":"trial_plan"}]},{"startsAt":1785508796316,"plans":[{"planId":"pro_plan"}]}] }); +const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1784744849888,"plans":[{"planId":"trial_plan"}]},{"startsAt":1785954449888,"plans":[{"planId":"pro_plan"}]}] }); ``` @param customerId - The ID of the customer to create the schedule for. @@ -1410,6 +1411,7 @@ const response = await client.features.update({ featureId: "deprecated-feature", @param newFeatureId - The new ID of the feature. Feature ID can only be updated if it's not being used by any customers. (optional) @returns The updated feature object. +- [`invoicesInsert`](docs/sdks/invoices/README.md#insert) - Inserts or updates up to 500 historical invoices without reading or mutating the billing processor. - [`invoicesList`](docs/sdks/invoices/README.md#list) - Lists invoices with cursor pagination and optional filters (customer, entity, status, processor). Pass `start_cursor: ""` (or omit) for the first page; use `next_cursor` from a prior response for subsequent pages. - [`keysMint`](docs/sdks/keys/README.md#mint) - Mints a per-customer token (a scoped `am_jwt_` credential) so a downstream / self-hosted app can call Autumn directly without your secret key. Returns a short-lived access token plus a rotating refresh token, both bound to the given customer. Authenticated with your secret key. - [`keysRefresh`](docs/sdks/keys/README.md#refresh) - Exchanges a refresh token (sent as the Bearer credential) for a freshly rotated access + refresh pair. Self-service for the token holder — no secret key required. The previous refresh token is honored for one rotation as a grace window; replaying an older one revokes the customer's tokens. diff --git a/packages/sdk/src/funcs/billing-create-schedule.ts b/packages/sdk/src/funcs/billing-create-schedule.ts index 7e2d5f8e1c..4f6d0f5892 100644 --- a/packages/sdk/src/funcs/billing-create-schedule.ts +++ b/packages/sdk/src/funcs/billing-create-schedule.ts @@ -34,7 +34,7 @@ import { Result } from "../types/fp.js"; * @example * ```typescript * // Schedule a transition from a trial plan to a paid plan - * const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1784299196316,"plans":[{"planId":"trial_plan"}]},{"startsAt":1785508796316,"plans":[{"planId":"pro_plan"}]}] }); + * const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1784744849888,"plans":[{"planId":"trial_plan"}]},{"startsAt":1785954449888,"plans":[{"planId":"pro_plan"}]}] }); * ``` * * @param customerId - The ID of the customer to create the schedule for. diff --git a/packages/sdk/src/funcs/invoices-insert.ts b/packages/sdk/src/funcs/invoices-insert.ts new file mode 100644 index 0000000000..a62c9bd95a --- /dev/null +++ b/packages/sdk/src/funcs/invoices-insert.ts @@ -0,0 +1,165 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { AutumnCore } from "../core.js"; +import { encodeJSON, encodeSimple } from "../lib/encodings.js"; +import { matchStatusCode } from "../lib/http.js"; +import * as M from "../lib/matchers.js"; +import { compactMap } from "../lib/primitives.js"; +import { safeParse } from "../lib/schemas.js"; +import { RequestOptions } from "../lib/sdks.js"; +import { extractSecurity, resolveGlobalSecurity } from "../lib/security.js"; +import { pathToFunc } from "../lib/url.js"; +import { AutumnError } from "../models/autumn-error.js"; +import { + ConnectionError, + InvalidRequestError, + RequestAbortedError, + RequestTimeoutError, + UnexpectedClientError, +} from "../models/http-client-errors.js"; +import * as models from "../models/index.js"; +import { ResponseValidationError } from "../models/response-validation-error.js"; +import { SDKValidationError } from "../models/sdk-validation-error.js"; +import { APICall, APIPromise } from "../types/async.js"; +import { Result } from "../types/fp.js"; + +/** + * Inserts or updates up to 500 historical invoices without reading or mutating the billing processor. + */ +export function invoicesInsert( + client: AutumnCore, + request: models.InsertInvoicesParams, + options?: RequestOptions, +): APIPromise< + Result< + models.InsertInvoicesResponse, + | AutumnError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + request, + options, + )); +} + +async function $do( + client: AutumnCore, + request: models.InsertInvoicesParams, + options?: RequestOptions, +): Promise< + [ + Result< + models.InsertInvoicesResponse, + | AutumnError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const parsed = safeParse( + request, + (value) => z.parse(models.InsertInvoicesParams$outboundSchema, value), + "Input validation failed", + ); + if (!parsed.ok) { + return [parsed, { status: "invalid" }]; + } + const payload = parsed.value; + const body = encodeJSON("body", payload, { explode: true }); + + const path = pathToFunc("/v1/invoices.insert")(); + + const headers = new Headers(compactMap({ + "Content-Type": "application/json", + Accept: "application/json", + "x-api-version": encodeSimple( + "x-api-version", + client._options.xApiVersion, + { explode: false, charEncoding: "none" }, + ), + })); + + const secConfig = await extractSecurity(client._options.secretKey); + const securityInput = secConfig == null ? {} : { secretKey: secConfig }; + const requestSecurity = resolveGlobalSecurity(securityInput); + + const context = { + options: client._options, + baseURL: options?.serverURL ?? client._baseURL ?? "", + operationID: "insertInvoices", + oAuth2Scopes: null, + + resolvedSecurity: requestSecurity, + + securitySource: client._options.secretKey, + retryConfig: options?.retries + || client._options.retryConfig + || { strategy: "none" }, + retryCodes: options?.retryCodes || ["429", "500", "502", "503", "504"], + }; + + const requestRes = client._createRequest(context, { + security: requestSecurity, + method: "POST", + baseURL: options?.serverURL, + path: path, + headers: headers, + body: body, + userAgent: client._options.userAgent, + timeoutMs: options?.timeoutMs || client._options.timeoutMs || -1, + }, options); + if (!requestRes.ok) { + return [requestRes, { status: "invalid" }]; + } + const req = requestRes.value; + + const doResult = await client._do(req, { + context, + isErrorStatusCode: (statusCode: number) => + matchStatusCode({ status: statusCode } as Response, ["4XX", "5XX"]), + retryConfig: context.retryConfig, + retryCodes: context.retryCodes, + }); + if (!doResult.ok) { + return [doResult, { status: "request-error", request: req }]; + } + const response = doResult.value; + + const [result] = await M.match< + models.InsertInvoicesResponse, + | AutumnError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.json(200, models.InsertInvoicesResponse$inboundSchema), + M.fail("4XX"), + M.fail("5XX"), + )(response, req); + if (!result.ok) { + return [result, { status: "complete", request: req, response }]; + } + + return [result, { status: "complete", request: req, response }]; +} diff --git a/packages/sdk/src/funcs/plans-update.ts b/packages/sdk/src/funcs/plans-update.ts index e710f9e979..e979aed821 100644 --- a/packages/sdk/src/funcs/plans-update.ts +++ b/packages/sdk/src/funcs/plans-update.ts @@ -79,6 +79,7 @@ import { Result } from "../types/fp.js"; * @param allVersions - Apply the update diff to all versions of this plan. Mutually exclusive with disable_version. (optional) * @param forceVersion - Force versioning even when no customers exist. Mutually exclusive with disable_version. (optional) * @param updateVariantIds - Variant plan IDs to apply this update to. Empty or omitted means no propagation. (optional) + * @param updateLicenseParents - Parent plan versions that should receive this license-plan update. (optional) * @param variants - Additive variant updates for this base plan. Missing variants are created when name is provided. (optional) * @param isDefault - Whether this is the org's default plan. Cannot be true on a variant. (optional) * diff --git a/packages/sdk/src/models/attach-op.ts b/packages/sdk/src/models/attach-op.ts index 27af21b0c9..932eab0439 100644 --- a/packages/sdk/src/models/attach-op.ts +++ b/packages/sdk/src/models/attach-op.ts @@ -752,7 +752,7 @@ export type AttachPurchaseLimitInterval = ClosedEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type AttachPurchaseLimit = { /** @@ -767,6 +767,10 @@ export type AttachPurchaseLimit = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type AttachAutoTopup = { @@ -787,7 +791,7 @@ export type AttachAutoTopup = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: AttachPurchaseLimit | undefined; /** @@ -2602,6 +2606,7 @@ export type AttachPurchaseLimit$Outbound = { interval: string; interval_count: number; limit: number; + count?: number | undefined; }; /** @internal */ @@ -2613,6 +2618,7 @@ export const AttachPurchaseLimit$outboundSchema: z.ZodMiniType< interval: AttachPurchaseLimitInterval$outboundSchema, intervalCount: z._default(z.number(), 1), limit: z.number(), + count: z.optional(z.number()), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/billing-update-op.ts b/packages/sdk/src/models/billing-update-op.ts index 4a3ed6d731..2566f4ee43 100644 --- a/packages/sdk/src/models/billing-update-op.ts +++ b/packages/sdk/src/models/billing-update-op.ts @@ -768,7 +768,7 @@ export type BillingUpdatePurchaseLimitInterval = ClosedEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type BillingUpdatePurchaseLimit = { /** @@ -783,6 +783,10 @@ export type BillingUpdatePurchaseLimit = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type BillingUpdateAutoTopup = { @@ -803,7 +807,7 @@ export type BillingUpdateAutoTopup = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: BillingUpdatePurchaseLimit | undefined; /** @@ -2634,6 +2638,7 @@ export type BillingUpdatePurchaseLimit$Outbound = { interval: string; interval_count: number; limit: number; + count?: number | undefined; }; /** @internal */ @@ -2645,6 +2650,7 @@ export const BillingUpdatePurchaseLimit$outboundSchema: z.ZodMiniType< interval: BillingUpdatePurchaseLimitInterval$outboundSchema, intervalCount: z._default(z.number(), 1), limit: z.number(), + count: z.optional(z.number()), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/create-plan-op.ts b/packages/sdk/src/models/create-plan-op.ts index dec4d66470..24f80cae27 100644 --- a/packages/sdk/src/models/create-plan-op.ts +++ b/packages/sdk/src/models/create-plan-op.ts @@ -742,10 +742,6 @@ export type CreatePlanLicense = { prepaidOnly?: boolean | undefined; customize?: CreatePlanLicenseCustomize | null | undefined; metadata?: { [k: string]: any } | undefined; - /** - * Pin the link to a specific version of the license plan. Omitted, an existing link keeps its pinned version and a new link resolves to the latest. - */ - version?: number | undefined; }; /** @@ -824,7 +820,7 @@ export type CreatePlanPurchaseLimitIntervalRequestBody = ClosedEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type CreatePlanPurchaseLimitRequest = { /** @@ -839,6 +835,10 @@ export type CreatePlanPurchaseLimitRequest = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type CreatePlanAutoTopupRequest = { @@ -859,7 +859,7 @@ export type CreatePlanAutoTopupRequest = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: CreatePlanPurchaseLimitRequest | undefined; /** @@ -1972,7 +1972,7 @@ export type CreatePlanVariantDetailsPurchaseLimitInterval = OpenEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type CreatePlanVariantDetailsPurchaseLimit = { /** @@ -1987,6 +1987,10 @@ export type CreatePlanVariantDetailsPurchaseLimit = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type CreatePlanVariantDetailsAutoTopup = { @@ -2007,7 +2011,7 @@ export type CreatePlanVariantDetailsAutoTopup = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: CreatePlanVariantDetailsPurchaseLimit | undefined; /** @@ -3567,7 +3571,6 @@ export type CreatePlanLicense$Outbound = { prepaid_only?: boolean | undefined; customize?: CreatePlanLicenseCustomize$Outbound | null | undefined; metadata?: { [k: string]: any } | undefined; - version?: number | undefined; }; /** @internal */ @@ -3583,7 +3586,6 @@ export const CreatePlanLicense$outboundSchema: z.ZodMiniType< z.nullable(z.lazy(() => CreatePlanLicenseCustomize$outboundSchema)), ), metadata: z.optional(z.record(z.string(), z.any())), - version: z.optional(z.int()), }), z.transform((v) => { return remap$(v, { @@ -3690,6 +3692,7 @@ export type CreatePlanPurchaseLimitRequest$Outbound = { interval: string; interval_count: number; limit: number; + count?: number | undefined; }; /** @internal */ @@ -3701,6 +3704,7 @@ export const CreatePlanPurchaseLimitRequest$outboundSchema: z.ZodMiniType< interval: CreatePlanPurchaseLimitIntervalRequestBody$outboundSchema, intervalCount: z._default(z.number(), 1), limit: z.number(), + count: z.optional(z.number()), }), z.transform((v) => { return remap$(v, { @@ -5104,6 +5108,7 @@ export const CreatePlanVariantDetailsPurchaseLimit$inboundSchema: z.ZodMiniType< interval: CreatePlanVariantDetailsPurchaseLimitInterval$inboundSchema, interval_count: z._default(types.number(), 1), limit: types.number(), + count: types.optional(types.number()), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/create-schedule-op.ts b/packages/sdk/src/models/create-schedule-op.ts index 60019d0e99..ed42c4b86b 100644 --- a/packages/sdk/src/models/create-schedule-op.ts +++ b/packages/sdk/src/models/create-schedule-op.ts @@ -767,7 +767,7 @@ export type CreateSchedulePurchaseLimitInterval2 = ClosedEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type CreateSchedulePurchaseLimit2 = { /** @@ -782,6 +782,10 @@ export type CreateSchedulePurchaseLimit2 = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type CreateScheduleAutoTopup2 = { @@ -802,7 +806,7 @@ export type CreateScheduleAutoTopup2 = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: CreateSchedulePurchaseLimit2 | undefined; /** @@ -2140,6 +2144,7 @@ export type CreateSchedulePurchaseLimit2$Outbound = { interval: string; interval_count: number; limit: number; + count?: number | undefined; }; /** @internal */ @@ -2151,6 +2156,7 @@ export const CreateSchedulePurchaseLimit2$outboundSchema: z.ZodMiniType< interval: CreateSchedulePurchaseLimitInterval2$outboundSchema, intervalCount: z._default(z.number(), 1), limit: z.number(), + count: z.optional(z.number()), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/customer-data.ts b/packages/sdk/src/models/customer-data.ts index d7b53b00bc..85e02d59cd 100644 --- a/packages/sdk/src/models/customer-data.ts +++ b/packages/sdk/src/models/customer-data.ts @@ -24,7 +24,7 @@ export type CustomerDataPurchaseLimitInterval = ClosedEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type CustomerDataPurchaseLimit = { /** @@ -39,6 +39,10 @@ export type CustomerDataPurchaseLimit = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type CustomerDataAutoTopup = { @@ -59,7 +63,7 @@ export type CustomerDataAutoTopup = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: CustomerDataPurchaseLimit | undefined; /** @@ -301,6 +305,7 @@ export type CustomerDataPurchaseLimit$Outbound = { interval: string; interval_count: number; limit: number; + count?: number | undefined; }; /** @internal */ @@ -312,6 +317,7 @@ export const CustomerDataPurchaseLimit$outboundSchema: z.ZodMiniType< interval: CustomerDataPurchaseLimitInterval$outboundSchema, intervalCount: z._default(z.number(), 1), limit: z.number(), + count: z.optional(z.number()), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/get-or-create-customer-op.ts b/packages/sdk/src/models/get-or-create-customer-op.ts index 8bd7e9a076..de248bb322 100644 --- a/packages/sdk/src/models/get-or-create-customer-op.ts +++ b/packages/sdk/src/models/get-or-create-customer-op.ts @@ -32,7 +32,7 @@ export type GetOrCreateCustomerPurchaseLimitInterval = ClosedEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type GetOrCreateCustomerPurchaseLimit = { /** @@ -47,6 +47,10 @@ export type GetOrCreateCustomerPurchaseLimit = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type GetOrCreateCustomerAutoTopup = { @@ -67,7 +71,7 @@ export type GetOrCreateCustomerAutoTopup = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: GetOrCreateCustomerPurchaseLimit | undefined; /** @@ -314,6 +318,7 @@ export type GetOrCreateCustomerPurchaseLimit$Outbound = { interval: string; interval_count: number; limit: number; + count?: number | undefined; }; /** @internal */ @@ -325,6 +330,7 @@ export const GetOrCreateCustomerPurchaseLimit$outboundSchema: z.ZodMiniType< interval: GetOrCreateCustomerPurchaseLimitInterval$outboundSchema, intervalCount: z._default(z.number(), 1), limit: z.number(), + count: z.optional(z.number()), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/get-plan-op.ts b/packages/sdk/src/models/get-plan-op.ts index deed3d3a32..bf5e1279f3 100644 --- a/packages/sdk/src/models/get-plan-op.ts +++ b/packages/sdk/src/models/get-plan-op.ts @@ -896,7 +896,7 @@ export type GetPlanVariantDetailsPurchaseLimitInterval = OpenEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type GetPlanVariantDetailsPurchaseLimit = { /** @@ -911,6 +911,10 @@ export type GetPlanVariantDetailsPurchaseLimit = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type GetPlanVariantDetailsAutoTopup = { @@ -931,7 +935,7 @@ export type GetPlanVariantDetailsAutoTopup = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: GetPlanVariantDetailsPurchaseLimit | undefined; /** @@ -2503,6 +2507,7 @@ export const GetPlanVariantDetailsPurchaseLimit$inboundSchema: z.ZodMiniType< interval: GetPlanVariantDetailsPurchaseLimitInterval$inboundSchema, interval_count: z._default(types.number(), 1), limit: types.number(), + count: types.optional(types.number()), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/index.ts b/packages/sdk/src/models/index.ts index 108897300b..c38815d4e2 100644 --- a/packages/sdk/src/models/index.ts +++ b/packages/sdk/src/models/index.ts @@ -34,6 +34,7 @@ export * from "./get-plan-op.js"; export * from "./get-revenue-cat-keys-op.js"; export * from "./http-client-errors.js"; export * from "./import-op.js"; +export * from "./insert-invoices-op.js"; export * from "./link-revenue-cat-op.js"; export * from "./list-customers-op.js"; export * from "./list-entities-op.js"; diff --git a/packages/sdk/src/models/insert-invoices-op.ts b/packages/sdk/src/models/insert-invoices-op.ts new file mode 100644 index 0000000000..b48d34e3cc --- /dev/null +++ b/packages/sdk/src/models/insert-invoices-op.ts @@ -0,0 +1,333 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { remap as remap$ } from "../lib/primitives.js"; +import { safeParse } from "../lib/schemas.js"; +import * as openEnums from "../types/enums.js"; +import { ClosedEnum, OpenEnum } from "../types/enums.js"; +import { Result as SafeParseResult } from "../types/fp.js"; +import * as types from "../types/primitives.js"; +import { SDKValidationError } from "./sdk-validation-error.js"; + +export type InsertInvoicesGlobals = { + xApiVersion?: string | undefined; +}; + +/** + * The billing processor that owns this invoice. + */ +export const InsertInvoicesProcessorTypeRequest = { + Stripe: "stripe", + Revenuecat: "revenuecat", +} as const; +/** + * The billing processor that owns this invoice. + */ +export type InsertInvoicesProcessorTypeRequest = ClosedEnum< + typeof InsertInvoicesProcessorTypeRequest +>; + +/** + * The invoice status. + */ +export const InsertInvoicesStatus = { + Draft: "draft", + Open: "open", + Void: "void", + Paid: "paid", + Uncollectible: "uncollectible", +} as const; +/** + * The invoice status. + */ +export type InsertInvoicesStatus = ClosedEnum; + +export type InvoiceRequestBody = { + /** + * The customer this invoice belongs to. + */ + customerId: string; + /** + * Plan IDs represented by this invoice. + */ + planIds?: Array | undefined; + /** + * The processor's stable invoice ID. + */ + stripeId: string; + /** + * The billing processor that owns this invoice. + */ + processorType?: InsertInvoicesProcessorTypeRequest | undefined; + /** + * The invoice status. + */ + status: InsertInvoicesStatus; + /** + * The invoice total in major currency units. + */ + total: number; + /** + * The amount paid in major currency units. + */ + amountPaid?: number | null | undefined; + /** + * The refunded amount in major currency units. + */ + refundedAmount?: number | undefined; + /** + * The currency code. Defaults to the organization's default currency. + */ + currency?: string | undefined; + /** + * The invoice creation timestamp in milliseconds. + */ + createdAt: number; + /** + * The hosted invoice URL, when available. + */ + hostedInvoiceUrl?: string | null | undefined; +}; + +export type InsertInvoicesParams = { + /** + * Invoices to insert or update, in response order. + */ + invoices: Array; +}; + +/** + * The billing processor that owns this invoice. + */ +export const InsertInvoicesProcessorTypeResponse = { + Stripe: "stripe", + Revenuecat: "revenuecat", +} as const; +/** + * The billing processor that owns this invoice. + */ +export type InsertInvoicesProcessorTypeResponse = OpenEnum< + typeof InsertInvoicesProcessorTypeResponse +>; + +export type InsertInvoicesInvoiceResponse = { + /** + * Array of plan IDs included in this invoice + */ + planIds: Array; + /** + * The Stripe invoice ID + */ + stripeId: string; + /** + * The billing processor that owns this invoice. + */ + processorType: InsertInvoicesProcessorTypeResponse; + /** + * The status of the invoice + */ + status: string; + /** + * The total amount of the invoice + */ + total: number; + /** + * The currency code for the invoice + */ + currency: string; + /** + * Timestamp when the invoice was created + */ + createdAt: number; + /** + * URL to the Stripe-hosted invoice page + */ + hostedInvoiceUrl?: string | null | undefined; + /** + * The Autumn invoice ID. + */ + id: string; + /** + * The customer this invoice belongs to. + */ + customerId: string; + /** + * The amount paid in major currency units. + */ + amountPaid: number | null; + /** + * The refunded amount in major currency units. + */ + refundedAmount: number; +}; + +/** + * OK + */ +export type InsertInvoicesResponse = { + /** + * Inserted or updated invoices in request order. + */ + invoices: Array; +}; + +/** @internal */ +export const InsertInvoicesProcessorTypeRequest$outboundSchema: z.ZodMiniEnum< + typeof InsertInvoicesProcessorTypeRequest +> = z.enum(InsertInvoicesProcessorTypeRequest); + +/** @internal */ +export const InsertInvoicesStatus$outboundSchema: z.ZodMiniEnum< + typeof InsertInvoicesStatus +> = z.enum(InsertInvoicesStatus); + +/** @internal */ +export type InvoiceRequestBody$Outbound = { + customer_id: string; + plan_ids?: Array | undefined; + stripe_id: string; + processor_type: string; + status: string; + total: number; + amount_paid?: number | null | undefined; + refunded_amount: number; + currency?: string | undefined; + created_at: number; + hosted_invoice_url?: string | null | undefined; +}; + +/** @internal */ +export const InvoiceRequestBody$outboundSchema: z.ZodMiniType< + InvoiceRequestBody$Outbound, + InvoiceRequestBody +> = z.pipe( + z.object({ + customerId: z.string(), + planIds: z.optional(z.array(z.string())), + stripeId: z.string(), + processorType: z._default( + InsertInvoicesProcessorTypeRequest$outboundSchema, + "stripe", + ), + status: InsertInvoicesStatus$outboundSchema, + total: z.number(), + amountPaid: z.optional(z.nullable(z.number())), + refundedAmount: z._default(z.number(), 0), + currency: z.optional(z.string()), + createdAt: z.number(), + hostedInvoiceUrl: z.optional(z.nullable(z.string())), + }), + z.transform((v) => { + return remap$(v, { + customerId: "customer_id", + planIds: "plan_ids", + stripeId: "stripe_id", + processorType: "processor_type", + amountPaid: "amount_paid", + refundedAmount: "refunded_amount", + createdAt: "created_at", + hostedInvoiceUrl: "hosted_invoice_url", + }); + }), +); + +export function invoiceRequestBodyToJSON( + invoiceRequestBody: InvoiceRequestBody, +): string { + return JSON.stringify( + InvoiceRequestBody$outboundSchema.parse(invoiceRequestBody), + ); +} + +/** @internal */ +export type InsertInvoicesParams$Outbound = { + invoices: Array; +}; + +/** @internal */ +export const InsertInvoicesParams$outboundSchema: z.ZodMiniType< + InsertInvoicesParams$Outbound, + InsertInvoicesParams +> = z.object({ + invoices: z.array(z.lazy(() => InvoiceRequestBody$outboundSchema)), +}); + +export function insertInvoicesParamsToJSON( + insertInvoicesParams: InsertInvoicesParams, +): string { + return JSON.stringify( + InsertInvoicesParams$outboundSchema.parse(insertInvoicesParams), + ); +} + +/** @internal */ +export const InsertInvoicesProcessorTypeResponse$inboundSchema: z.ZodMiniType< + InsertInvoicesProcessorTypeResponse, + unknown +> = openEnums.inboundSchema(InsertInvoicesProcessorTypeResponse); + +/** @internal */ +export const InsertInvoicesInvoiceResponse$inboundSchema: z.ZodMiniType< + InsertInvoicesInvoiceResponse, + unknown +> = z.pipe( + z.object({ + plan_ids: z.array(types.string()), + stripe_id: types.string(), + processor_type: z._default( + InsertInvoicesProcessorTypeResponse$inboundSchema, + "stripe", + ), + status: types.string(), + total: types.number(), + currency: types.string(), + created_at: types.number(), + hosted_invoice_url: z.optional(z.nullable(types.string())), + id: types.string(), + customer_id: types.string(), + amount_paid: types.nullable(types.number()), + refunded_amount: types.number(), + }), + z.transform((v) => { + return remap$(v, { + "plan_ids": "planIds", + "stripe_id": "stripeId", + "processor_type": "processorType", + "created_at": "createdAt", + "hosted_invoice_url": "hostedInvoiceUrl", + "customer_id": "customerId", + "amount_paid": "amountPaid", + "refunded_amount": "refundedAmount", + }); + }), +); + +export function insertInvoicesInvoiceResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => InsertInvoicesInvoiceResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'InsertInvoicesInvoiceResponse' from JSON`, + ); +} + +/** @internal */ +export const InsertInvoicesResponse$inboundSchema: z.ZodMiniType< + InsertInvoicesResponse, + unknown +> = z.object({ + invoices: z.array(z.lazy(() => InsertInvoicesInvoiceResponse$inboundSchema)), +}); + +export function insertInvoicesResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => InsertInvoicesResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'InsertInvoicesResponse' from JSON`, + ); +} diff --git a/packages/sdk/src/models/list-invoices-op.ts b/packages/sdk/src/models/list-invoices-op.ts index f071ebd0f9..006dc30d4c 100644 --- a/packages/sdk/src/models/list-invoices-op.ts +++ b/packages/sdk/src/models/list-invoices-op.ts @@ -24,11 +24,13 @@ export const ListInvoicesStatus = { } as const; export type ListInvoicesStatus = ClosedEnum; -export const ProcessorTypeRequest = { +export const ListInvoicesProcessorTypeRequest = { Stripe: "stripe", Revenuecat: "revenuecat", } as const; -export type ProcessorTypeRequest = ClosedEnum; +export type ListInvoicesProcessorTypeRequest = ClosedEnum< + typeof ListInvoicesProcessorTypeRequest +>; export type ListInvoicesParams = { /** @@ -54,7 +56,7 @@ export type ListInvoicesParams = { /** * Filter by billing processor (stripe, revenuecat). Invoices recorded before processor tracking count as stripe. */ - processorTypes?: Array | undefined; + processorTypes?: Array | undefined; }; /** @@ -146,9 +148,9 @@ export const ListInvoicesStatus$outboundSchema: z.ZodMiniEnum< > = z.enum(ListInvoicesStatus); /** @internal */ -export const ProcessorTypeRequest$outboundSchema: z.ZodMiniEnum< - typeof ProcessorTypeRequest -> = z.enum(ProcessorTypeRequest); +export const ListInvoicesProcessorTypeRequest$outboundSchema: z.ZodMiniEnum< + typeof ListInvoicesProcessorTypeRequest +> = z.enum(ListInvoicesProcessorTypeRequest); /** @internal */ export type ListInvoicesParams$Outbound = { @@ -171,7 +173,9 @@ export const ListInvoicesParams$outboundSchema: z.ZodMiniType< customerId: z.optional(z.string()), entityId: z.optional(z.string()), status: z.optional(z.array(ListInvoicesStatus$outboundSchema)), - processorTypes: z.optional(z.array(ProcessorTypeRequest$outboundSchema)), + processorTypes: z.optional( + z.array(ListInvoicesProcessorTypeRequest$outboundSchema), + ), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/list-plans-op.ts b/packages/sdk/src/models/list-plans-op.ts index 78221e2253..41c54675da 100644 --- a/packages/sdk/src/models/list-plans-op.ts +++ b/packages/sdk/src/models/list-plans-op.ts @@ -887,7 +887,7 @@ export type ListPlansVariantDetailsPurchaseLimitInterval = OpenEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type ListPlansVariantDetailsPurchaseLimit = { /** @@ -902,6 +902,10 @@ export type ListPlansVariantDetailsPurchaseLimit = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type ListPlansVariantDetailsAutoTopup = { @@ -922,7 +926,7 @@ export type ListPlansVariantDetailsAutoTopup = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: ListPlansVariantDetailsPurchaseLimit | undefined; /** @@ -2471,6 +2475,7 @@ export const ListPlansVariantDetailsPurchaseLimit$inboundSchema: z.ZodMiniType< interval: ListPlansVariantDetailsPurchaseLimitInterval$inboundSchema, interval_count: z._default(types.number(), 1), limit: types.number(), + count: types.optional(types.number()), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/plan.ts b/packages/sdk/src/models/plan.ts index 861af25ded..178af66132 100644 --- a/packages/sdk/src/models/plan.ts +++ b/packages/sdk/src/models/plan.ts @@ -869,7 +869,7 @@ export type PlanVariantDetailsPurchaseLimitInterval = OpenEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type PlanVariantDetailsPurchaseLimit = { /** @@ -884,6 +884,10 @@ export type PlanVariantDetailsPurchaseLimit = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type PlanVariantDetailsAutoTopup = { @@ -904,7 +908,7 @@ export type PlanVariantDetailsAutoTopup = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: PlanVariantDetailsPurchaseLimit | undefined; /** @@ -2411,6 +2415,7 @@ export const PlanVariantDetailsPurchaseLimit$inboundSchema: z.ZodMiniType< interval: PlanVariantDetailsPurchaseLimitInterval$inboundSchema, interval_count: z._default(types.number(), 1), limit: types.number(), + count: types.optional(types.number()), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/preview-attach-op.ts b/packages/sdk/src/models/preview-attach-op.ts index 23b0f12466..b395a92c85 100644 --- a/packages/sdk/src/models/preview-attach-op.ts +++ b/packages/sdk/src/models/preview-attach-op.ts @@ -769,7 +769,7 @@ export type PreviewAttachPurchaseLimitInterval = ClosedEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type PreviewAttachPurchaseLimit = { /** @@ -784,6 +784,10 @@ export type PreviewAttachPurchaseLimit = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type PreviewAttachAutoTopup = { @@ -804,7 +808,7 @@ export type PreviewAttachAutoTopup = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: PreviewAttachPurchaseLimit | undefined; /** @@ -2963,6 +2967,7 @@ export type PreviewAttachPurchaseLimit$Outbound = { interval: string; interval_count: number; limit: number; + count?: number | undefined; }; /** @internal */ @@ -2974,6 +2979,7 @@ export const PreviewAttachPurchaseLimit$outboundSchema: z.ZodMiniType< interval: PreviewAttachPurchaseLimitInterval$outboundSchema, intervalCount: z._default(z.number(), 1), limit: z.number(), + count: z.optional(z.number()), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/preview-update-op.ts b/packages/sdk/src/models/preview-update-op.ts index 6e2a2d1cfb..2ddf0ba99e 100644 --- a/packages/sdk/src/models/preview-update-op.ts +++ b/packages/sdk/src/models/preview-update-op.ts @@ -769,7 +769,7 @@ export type PreviewUpdatePurchaseLimitInterval = ClosedEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type PreviewUpdatePurchaseLimit = { /** @@ -784,6 +784,10 @@ export type PreviewUpdatePurchaseLimit = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type PreviewUpdateAutoTopup = { @@ -804,7 +808,7 @@ export type PreviewUpdateAutoTopup = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: PreviewUpdatePurchaseLimit | undefined; /** @@ -2917,6 +2921,7 @@ export type PreviewUpdatePurchaseLimit$Outbound = { interval: string; interval_count: number; limit: number; + count?: number | undefined; }; /** @internal */ @@ -2928,6 +2933,7 @@ export const PreviewUpdatePurchaseLimit$outboundSchema: z.ZodMiniType< interval: PreviewUpdatePurchaseLimitInterval$outboundSchema, intervalCount: z._default(z.number(), 1), limit: z.number(), + count: z.optional(z.number()), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/setup-payment-op.ts b/packages/sdk/src/models/setup-payment-op.ts index d806443282..65bab2b342 100644 --- a/packages/sdk/src/models/setup-payment-op.ts +++ b/packages/sdk/src/models/setup-payment-op.ts @@ -767,7 +767,7 @@ export type SetupPaymentPurchaseLimitInterval = ClosedEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type SetupPaymentPurchaseLimit = { /** @@ -782,6 +782,10 @@ export type SetupPaymentPurchaseLimit = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type SetupPaymentAutoTopup = { @@ -802,7 +806,7 @@ export type SetupPaymentAutoTopup = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: SetupPaymentPurchaseLimit | undefined; /** @@ -2537,6 +2541,7 @@ export type SetupPaymentPurchaseLimit$Outbound = { interval: string; interval_count: number; limit: number; + count?: number | undefined; }; /** @internal */ @@ -2548,6 +2553,7 @@ export const SetupPaymentPurchaseLimit$outboundSchema: z.ZodMiniType< interval: SetupPaymentPurchaseLimitInterval$outboundSchema, intervalCount: z._default(z.number(), 1), limit: z.number(), + count: z.optional(z.number()), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/update-customer-op.ts b/packages/sdk/src/models/update-customer-op.ts index 98437d4b30..2ea2f18148 100644 --- a/packages/sdk/src/models/update-customer-op.ts +++ b/packages/sdk/src/models/update-customer-op.ts @@ -35,7 +35,7 @@ export type UpdateCustomerPurchaseLimitIntervalRequestBody = ClosedEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type UpdateCustomerPurchaseLimitRequest = { /** @@ -50,6 +50,10 @@ export type UpdateCustomerPurchaseLimitRequest = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type UpdateCustomerAutoTopupRequest = { @@ -70,7 +74,7 @@ export type UpdateCustomerAutoTopupRequest = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: UpdateCustomerPurchaseLimitRequest | undefined; /** @@ -1098,6 +1102,7 @@ export type UpdateCustomerPurchaseLimitRequest$Outbound = { interval: string; interval_count: number; limit: number; + count?: number | undefined; }; /** @internal */ @@ -1109,6 +1114,7 @@ export const UpdateCustomerPurchaseLimitRequest$outboundSchema: z.ZodMiniType< interval: UpdateCustomerPurchaseLimitIntervalRequestBody$outboundSchema, intervalCount: z._default(z.number(), 1), limit: z.number(), + count: z.optional(z.number()), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/update-plan-op.ts b/packages/sdk/src/models/update-plan-op.ts index 0f847edb90..6f2a857f17 100644 --- a/packages/sdk/src/models/update-plan-op.ts +++ b/packages/sdk/src/models/update-plan-op.ts @@ -742,10 +742,6 @@ export type UpdatePlanLicense = { prepaidOnly?: boolean | undefined; customize?: UpdatePlanLicenseCustomize | null | undefined; metadata?: { [k: string]: any } | undefined; - /** - * Pin the link to a specific version of the license plan. Omitted, an existing link keeps its pinned version and a new link resolves to the latest. - */ - version?: number | undefined; }; /** @@ -824,7 +820,7 @@ export type UpdatePlanPurchaseLimitIntervalRequestBody = ClosedEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type UpdatePlanPurchaseLimitRequest = { /** @@ -839,6 +835,10 @@ export type UpdatePlanPurchaseLimitRequest = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type UpdatePlanAutoTopupRequest = { @@ -859,7 +859,7 @@ export type UpdatePlanAutoTopupRequest = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: UpdatePlanPurchaseLimitRequest | undefined; /** @@ -1034,6 +1034,11 @@ export type Migration = { includeCustom?: boolean | undefined; }; +export type UpdateLicenseParent = { + planId: string; + version: number; +}; + /** * Billing interval (e.g. 'month', 'year'). */ @@ -1472,7 +1477,7 @@ export type VariantPurchaseLimitInterval = ClosedEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type VariantPurchaseLimit = { /** @@ -1487,6 +1492,10 @@ export type VariantPurchaseLimit = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type VariantAutoTopup = { @@ -1507,7 +1516,7 @@ export type VariantAutoTopup = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: VariantPurchaseLimit | undefined; /** @@ -1806,6 +1815,10 @@ export type UpdatePlanParams = { * Variant plan IDs to apply this update to. Empty or omitted means no propagation. */ updateVariantIds?: Array | undefined; + /** + * Parent plan versions that should receive this license-plan update. + */ + updateLicenseParents?: Array | undefined; /** * Additive variant updates for this base plan. Missing variants are created when name is provided. */ @@ -2703,7 +2716,7 @@ export type UpdatePlanVariantDetailsPurchaseLimitInterval = OpenEnum< >; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ export type UpdatePlanVariantDetailsPurchaseLimit = { /** @@ -2718,6 +2731,10 @@ export type UpdatePlanVariantDetailsPurchaseLimit = { * Maximum number of auto top-ups allowed within the interval. */ limit: number; + /** + * Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged. + */ + count?: number | undefined; }; export type UpdatePlanVariantDetailsAutoTopup = { @@ -2738,7 +2755,7 @@ export type UpdatePlanVariantDetailsAutoTopup = { */ quantity: number; /** - * Optional rate limit to cap how often auto top-ups occur. + * Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups. */ purchaseLimit?: UpdatePlanVariantDetailsPurchaseLimit | undefined; /** @@ -4298,7 +4315,6 @@ export type UpdatePlanLicense$Outbound = { prepaid_only?: boolean | undefined; customize?: UpdatePlanLicenseCustomize$Outbound | null | undefined; metadata?: { [k: string]: any } | undefined; - version?: number | undefined; }; /** @internal */ @@ -4314,7 +4330,6 @@ export const UpdatePlanLicense$outboundSchema: z.ZodMiniType< z.nullable(z.lazy(() => UpdatePlanLicenseCustomize$outboundSchema)), ), metadata: z.optional(z.record(z.string(), z.any())), - version: z.optional(z.int()), }), z.transform((v) => { return remap$(v, { @@ -4423,6 +4438,7 @@ export type UpdatePlanPurchaseLimitRequest$Outbound = { interval: string; interval_count: number; limit: number; + count?: number | undefined; }; /** @internal */ @@ -4434,6 +4450,7 @@ export const UpdatePlanPurchaseLimitRequest$outboundSchema: z.ZodMiniType< interval: UpdatePlanPurchaseLimitIntervalRequestBody$outboundSchema, intervalCount: z._default(z.number(), 1), limit: z.number(), + count: z.optional(z.number()), }), z.transform((v) => { return remap$(v, { @@ -4779,6 +4796,36 @@ export function migrationToJSON(migration: Migration): string { return JSON.stringify(Migration$outboundSchema.parse(migration)); } +/** @internal */ +export type UpdateLicenseParent$Outbound = { + plan_id: string; + version: number; +}; + +/** @internal */ +export const UpdateLicenseParent$outboundSchema: z.ZodMiniType< + UpdateLicenseParent$Outbound, + UpdateLicenseParent +> = z.pipe( + z.object({ + planId: z.string(), + version: z.int(), + }), + z.transform((v) => { + return remap$(v, { + planId: "plan_id", + }); + }), +); + +export function updateLicenseParentToJSON( + updateLicenseParent: UpdateLicenseParent, +): string { + return JSON.stringify( + UpdateLicenseParent$outboundSchema.parse(updateLicenseParent), + ); +} + /** @internal */ export const PriceVariantInterval$outboundSchema: z.ZodMiniEnum< typeof PriceVariantInterval @@ -5296,6 +5343,7 @@ export type VariantPurchaseLimit$Outbound = { interval: string; interval_count: number; limit: number; + count?: number | undefined; }; /** @internal */ @@ -5307,6 +5355,7 @@ export const VariantPurchaseLimit$outboundSchema: z.ZodMiniType< interval: VariantPurchaseLimitInterval$outboundSchema, intervalCount: z._default(z.number(), 1), limit: z.number(), + count: z.optional(z.number()), }), z.transform((v) => { return remap$(v, { @@ -5745,6 +5794,7 @@ export type UpdatePlanParams$Outbound = { migration?: Migration$Outbound | undefined; force_version?: boolean | undefined; update_variant_ids?: Array | undefined; + update_license_parents?: Array | undefined; variants?: Array | undefined; is_default?: boolean | undefined; }; @@ -5788,6 +5838,9 @@ export const UpdatePlanParams$outboundSchema: z.ZodMiniType< migration: z.optional(z.lazy(() => Migration$outboundSchema)), forceVersion: z.optional(z.boolean()), updateVariantIds: z.optional(z.array(z.string())), + updateLicenseParents: z.optional( + z.array(z.lazy(() => UpdateLicenseParent$outboundSchema)), + ), variants: z.optional(z.array(z.lazy(() => Variant$outboundSchema))), isDefault: z.optional(z.boolean()), }), @@ -5805,6 +5858,7 @@ export const UpdatePlanParams$outboundSchema: z.ZodMiniType< allVersions: "all_versions", forceVersion: "force_version", updateVariantIds: "update_variant_ids", + updateLicenseParents: "update_license_parents", isDefault: "is_default", }); }), @@ -6837,6 +6891,7 @@ export const UpdatePlanVariantDetailsPurchaseLimit$inboundSchema: z.ZodMiniType< interval: UpdatePlanVariantDetailsPurchaseLimitInterval$inboundSchema, interval_count: z._default(types.number(), 1), limit: types.number(), + count: types.optional(types.number()), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/sdk/billing.ts b/packages/sdk/src/sdk/billing.ts index ded8bbc8c8..8bf3abf77f 100644 --- a/packages/sdk/src/sdk/billing.ts +++ b/packages/sdk/src/sdk/billing.ts @@ -93,7 +93,7 @@ export class Billing extends ClientSDK { * @example * ```typescript * // Schedule a transition from a trial plan to a paid plan - * const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1784299196316,"plans":[{"planId":"trial_plan"}]},{"startsAt":1785508796316,"plans":[{"planId":"pro_plan"}]}] }); + * const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1784744849888,"plans":[{"planId":"trial_plan"}]},{"startsAt":1785954449888,"plans":[{"planId":"pro_plan"}]}] }); * ``` * * @param customerId - The ID of the customer to create the schedule for. diff --git a/packages/sdk/src/sdk/invoices.ts b/packages/sdk/src/sdk/invoices.ts index 76d65ab90a..2bc21cdbfb 100644 --- a/packages/sdk/src/sdk/invoices.ts +++ b/packages/sdk/src/sdk/invoices.ts @@ -2,12 +2,27 @@ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. */ +import { invoicesInsert } from "../funcs/invoices-insert.js"; import { invoicesList } from "../funcs/invoices-list.js"; import { ClientSDK, RequestOptions } from "../lib/sdks.js"; import * as models from "../models/index.js"; import { unwrapAsync } from "../types/fp.js"; export class Invoices extends ClientSDK { + /** + * Inserts or updates up to 500 historical invoices without reading or mutating the billing processor. + */ + async insert( + request: models.InsertInvoicesParams, + options?: RequestOptions, + ): Promise { + return unwrapAsync(invoicesInsert( + this, + request, + options, + )); + } + /** * Lists invoices with cursor pagination and optional filters (customer, entity, status, processor). Pass `start_cursor: ""` (or omit) for the first page; use `next_cursor` from a prior response for subsequent pages. */ diff --git a/packages/sdk/src/sdk/plans.ts b/packages/sdk/src/sdk/plans.ts index b9f2d8052b..2c0a568fa7 100644 --- a/packages/sdk/src/sdk/plans.ts +++ b/packages/sdk/src/sdk/plans.ts @@ -217,6 +217,7 @@ export class Plans extends ClientSDK { * @param allVersions - Apply the update diff to all versions of this plan. Mutually exclusive with disable_version. (optional) * @param forceVersion - Force versioning even when no customers exist. Mutually exclusive with disable_version. (optional) * @param updateVariantIds - Variant plan IDs to apply this update to. Empty or omitted means no propagation. (optional) + * @param updateLicenseParents - Parent plan versions that should receive this license-plan update. (optional) * @param variants - Additive variant updates for this base plan. Missing variants are created when name is provided. (optional) * @param isDefault - Whether this is the org's default plan. Cannot be true on a variant. (optional) * diff --git a/scripts/tw/helpers/preflight.ts b/scripts/tw/helpers/preflight.ts index 33229e9288..8b57d1db35 100644 --- a/scripts/tw/helpers/preflight.ts +++ b/scripts/tw/helpers/preflight.ts @@ -62,7 +62,7 @@ const checkSecrets = (): PreflightProblem | undefined => { }; const checkGit = (ref: string): PreflightProblem | undefined => { - const dirty = git("status", "--porcelain"); + const dirty = git("status", "--porcelain", "--", ".", ":(exclude)ai"); if (dirty) { return { what: "uncommitted changes — workers clone origin/, so they won't see them", diff --git a/server/experiments/explainResetContext.ts b/server/experiments/explainResetContext.ts new file mode 100644 index 0000000000..7ac6e246b6 --- /dev/null +++ b/server/experiments/explainResetContext.ts @@ -0,0 +1,78 @@ +import "dotenv/config"; +import { sql } from "drizzle-orm"; +import { loadLocalEnv } from "../src/utils/envUtils"; + +loadLocalEnv(); + +const { initDrizzle } = await import("../src/db/initDrizzle"); +const { getResetEligibleCustomerEntitlementsPage } = await import( + "../src/internal/customers/cusProducts/cusEnts/repos/getResetEligibleCustomerEntitlementsPage" +); +const { buildResetContextByIdsQuery, getResetContextByIds } = await import( + "../src/internal/customers/cusProducts/cusEnts/repos/getResetContextByIds" +); + +// Benchmarks the batch reset worker's ID hydration: scans one keyset page, +// then times and explains the same ID-based query the worker runs. +// +// Run with: +// bun run experiments/explainResetContext.ts +// Options (env vars): +// RESET_CONTEXT_BATCH_SIZE=1000 number of cusEnt IDs to hydrate + +const main = async () => { + const batchSize = Number(process.env.RESET_CONTEXT_BATCH_SIZE ?? 1000); + const { db } = initDrizzle(); + const dueBefore = Date.now(); + + const page = await getResetEligibleCustomerEntitlementsPage({ + db, + dueBefore, + cursor: null, + limit: batchSize, + }); + const customerEntitlementIds = page.map((row) => row.id); + if (customerEntitlementIds.length === 0) { + console.log("Scanned 0 eligible customer entitlements"); + process.exit(0); + } + console.log(`Scanned ${page.length} eligible customer entitlement IDs\n`); + + // Full repo call, cold then warm. + for (const run of ["cold", "warm"]) { + const start = performance.now(); + const result = await getResetContextByIds({ + db, + customerEntitlementIds, + }); + const elapsed = performance.now() - start; + console.log( + `[hydrate][${run}] rows=${result.customerEntitlements.length}, missing=${result.missingIds.length}, wall-clock=${elapsed.toFixed(0)}ms`, + ); + } + + // Query-only timing, to separate row-mapping cost from DB cost. + { + const start = performance.now(); + const rows = await db.execute( + buildResetContextByIdsQuery({ customerEntitlementIds }), + ); + const elapsed = performance.now() - start; + console.log( + `[query-only] rows=${rows.length}, wall-clock=${elapsed.toFixed(0)}ms\n`, + ); + } + + console.log("--- EXPLAIN (ANALYZE, BUFFERS) ---"); + const explainResult = await db.execute( + sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${buildResetContextByIdsQuery({ customerEntitlementIds })}`, + ); + for (const row of explainResult) { + const line = (row as Record)["QUERY PLAN"]; + if (typeof line === "string") console.log(line); + } + + process.exit(0); +}; + +await main(); diff --git a/server/experiments/explainResetCount.ts b/server/experiments/explainResetCount.ts new file mode 100644 index 0000000000..4030f0d1bd --- /dev/null +++ b/server/experiments/explainResetCount.ts @@ -0,0 +1,58 @@ +import "dotenv/config"; +import { sql } from "drizzle-orm"; +import { loadLocalEnv } from "../src/utils/envUtils"; + +loadLocalEnv(); + +const { initDrizzle } = await import("../src/db/initDrizzle"); +const { buildCountResetEligibleQuery, countResetEligibleCustomerEntitlements } = + await import( + "../src/internal/customers/cusProducts/cusEnts/repos/countResetEligibleCustomerEntitlements" + ); + +// Benchmarks the backlog-gauge count (countResetEligibleCustomerEntitlements) +// that the V2 reset cron logs every few minutes — verifies the capped count +// stays cheap against a large overdue backlog. +// +// Run with: +// bun run experiments/explainResetCount.ts +// Options (env vars): +// RESET_COUNT_CAP=1000000 count cap (default 1,000,000, same as the cron) + +const main = async () => { + const cap = Number(process.env.RESET_COUNT_CAP ?? 1_000_000); + const dueBefore = Date.now(); + const { db } = initDrizzle(); + + // The bunfig preload overlays server/.env.local (worktree Neon branch) on + // top of Infisical — run with PW_MODE=1 to skip the overlay and hit the + // Infisical-provided DB (e.g. prod). + const dbHost = new URL(process.env.DATABASE_URL ?? "").host; + console.log(`DB target: ${dbHost}\n`); + + for (const run of ["cold", "warm"]) { + const start = performance.now(); + const result = await countResetEligibleCustomerEntitlements({ + db, + dueBefore, + cap, + }); + const elapsed = performance.now() - start; + console.log( + `[count][${run}] count=${result.count}, capped=${result.capped}, wall-clock=${elapsed.toFixed(0)}ms`, + ); + } + + console.log("\n--- EXPLAIN (ANALYZE, BUFFERS) ---"); + const explainResult = await db.execute( + sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${buildCountResetEligibleQuery({ dueBefore, cap })}`, + ); + for (const row of explainResult) { + const line = (row as Record)["QUERY PLAN"]; + if (typeof line === "string") console.log(line); + } + + process.exit(0); +}; + +await main(); diff --git a/server/experiments/explainResetScan.ts b/server/experiments/explainResetScan.ts new file mode 100644 index 0000000000..774c4f76ad --- /dev/null +++ b/server/experiments/explainResetScan.ts @@ -0,0 +1,127 @@ +import "dotenv/config"; +import { sql } from "drizzle-orm"; +import { loadLocalEnv } from "../src/utils/envUtils"; + +loadLocalEnv(); + +const { initDrizzle } = await import("../src/db/initDrizzle"); +const { + buildResetEligiblePageQuery, + getResetEligibleCustomerEntitlementsPage, +} = await import( + "../src/internal/customers/cusProducts/cusEnts/repos/getResetEligibleCustomerEntitlementsPage" +); +const { countResetEligibleCustomerEntitlements } = await import( + "../src/internal/customers/cusProducts/cusEnts/repos/countResetEligibleCustomerEntitlements" +); + +// Benchmarks the REAL reset scan (the exact repo query the V2 cron runs, +// including the reset_by_invoice + expires_at predicate): +// 1. single page at several batch sizes (cold + warm) +// 2. cursor walk: N pages deep, to verify keyset pagination stays flat +// 3. EXPLAIN of the exact statement +// +// NOTE: requires migration 0047 (reset_by_invoice column + +// idx_customer_entitlements_reset_scan) on the target DB. The plan should use +// idx_customer_entitlements_reset_scan with NO Incremental Sort node. +// +// Run with (PW_MODE=1 skips the .env.local worktree-DB overlay): +// PW_MODE=1 infisical run --env=prod --recursive -- bun run experiments/explainResetScan.ts +// Options (env vars): +// BATCH_SIZES=1000,10000,100000 page sizes to test +// WALK_PAGE_SIZE=10000 cursor-walk page size +// WALK_PAGES=20 cursor-walk page count +// RUN_BACKLOG_COUNT=true also run the capped backlog count + +const main = async () => { + const batchSizes = (process.env.BATCH_SIZES ?? "1000,10000,100000") + .split(",") + .map(Number); + const walkPageSize = Number(process.env.WALK_PAGE_SIZE ?? 10_000); + const walkPages = Number(process.env.WALK_PAGES ?? 20); + const dueBefore = Date.now(); + const { db } = initDrizzle(); + + const dbHost = new URL(process.env.DATABASE_URL ?? "").host; + console.log(`DB target: ${dbHost}\n`); + + // 1. Single page at each batch size. Wall-clock includes network transfer + // of the rows, so big batches partly measure payload size, not DB work — + // the EXPLAIN execution time at the end isolates the DB-side cost. + console.log("--- single page per batch size (cold, warm) ---"); + for (const limit of batchSizes) { + for (const run of ["cold", "warm"]) { + const start = performance.now(); + const page = await getResetEligibleCustomerEntitlementsPage({ + db, + dueBefore, + cursor: null, + limit, + }); + const elapsed = performance.now() - start; + console.log( + `[size=${limit}][${run}] rows=${page.length}, wall-clock=${elapsed.toFixed(0)}ms`, + ); + } + } + + // 2. Cursor walk: page N should cost the same as page 1. + console.log( + `\n--- cursor walk: ${walkPages} pages x ${walkPageSize} rows ---`, + ); + let cursor: { nextResetAt: number; id: string } | null = null; + for (let page = 1; page <= walkPages; page++) { + const start = performance.now(); + const rows = await getResetEligibleCustomerEntitlementsPage({ + db, + dueBefore, + cursor, + limit: walkPageSize, + }); + const elapsed = performance.now() - start; + console.log( + `[page ${page}] rows=${rows.length}, wall-clock=${elapsed.toFixed(0)}ms, depth=${(page * walkPageSize).toLocaleString()}`, + ); + if (rows.length < walkPageSize) { + console.log("(drained)"); + break; + } + const lastRow = rows[rows.length - 1]; + cursor = { nextResetAt: lastRow.nextResetAt, id: lastRow.id }; + } + + // 3. DB-side cost of the biggest batch size, isolated from network. + const maxSize = Math.max(...batchSizes); + console.log(`\n--- EXPLAIN (ANALYZE, BUFFERS), size=${maxSize} ---`); + const explainResult = await db.execute( + sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${buildResetEligiblePageQuery( + { + db, + dueBefore, + cursor: null, + limit: maxSize, + }, + )}`, + ); + for (const row of explainResult) { + const line = (row as Record)["QUERY PLAN"]; + if (typeof line === "string") console.log(line); + } + + if (process.env.RUN_BACKLOG_COUNT === "true") { + const start = performance.now(); + const result = await countResetEligibleCustomerEntitlements({ + db, + dueBefore, + cap: 1_000_000, + }); + const elapsed = performance.now() - start; + console.log( + `\n[backlog] overdue-eligible rows=${result.count} (capped=${result.capped}), wall-clock=${elapsed.toFixed(0)}ms`, + ); + } + + process.exit(0); +}; + +await main(); diff --git a/server/src/_luaScriptsV2/syncDirty/claimSyncDirty.lua b/server/src/_luaScriptsV2/syncDirty/claimSyncDirty.lua new file mode 100644 index 0000000000..e5979e4fdd --- /dev/null +++ b/server/src/_luaScriptsV2/syncDirty/claimSyncDirty.lua @@ -0,0 +1,39 @@ +-- Atomically claim the customer's dirty state for a drain: move dirty -> claim, +-- merging any leftover claim from a crashed worker (uw:* snapshots keep the +-- newer ts; other marker fields are a no-op union). Stamps a fresh generation +-- token for compare-and-delete, clears the signal marker so post-claim writes +-- re-signal, and returns the full claim hash (or nil when empty). +-- KEYS[1] dirty hash, KEYS[2] claim hash, KEYS[3] signal marker +-- ARGV[1] claim TTL seconds, ARGV[2] generation token +local dirtyKey = KEYS[1] +local claimKey = KEYS[2] +local signalKey = KEYS[3] +local claimTtl = tonumber(ARGV[1]) +local generation = ARGV[2] +if redis.call('EXISTS', dirtyKey) == 1 then + if redis.call('EXISTS', claimKey) == 1 then + local fields = redis.call('HGETALL', dirtyKey) + for i = 1, #fields, 2 do + local field = fields[i] + local value = fields[i + 1] + if string.sub(field, 1, 3) == 'uw:' then + local existing = redis.call('HGET', claimKey, field) + if not existing or cjson.decode(value).ts >= cjson.decode(existing).ts then + redis.call('HSET', claimKey, field, value) + end + else + redis.call('HSET', claimKey, field, value) + end + end + redis.call('DEL', dirtyKey) + else + redis.call('RENAME', dirtyKey, claimKey) + end +end +redis.call('DEL', signalKey) +if redis.call('EXISTS', claimKey) == 1 then + redis.call('HSET', claimKey, '__gen', generation) + redis.call('EXPIRE', claimKey, claimTtl) + return redis.call('HGETALL', claimKey) +end +return nil diff --git a/server/src/_luaScriptsV2/syncDirty/clearSyncClaim.lua b/server/src/_luaScriptsV2/syncDirty/clearSyncClaim.lua new file mode 100644 index 0000000000..f09bdd8e74 --- /dev/null +++ b/server/src/_luaScriptsV2/syncDirty/clearSyncClaim.lua @@ -0,0 +1,7 @@ +-- Compare-and-delete: remove the claim only if the generation token still +-- matches (a redelivered worker may have re-claimed and merged new state). +-- KEYS[1] claim hash · ARGV[1] generation token +if redis.call('HGET', KEYS[1], '__gen') == ARGV[1] then + return redis.call('DEL', KEYS[1]) +end +return 0 diff --git a/server/src/_luaScriptsV2/syncDirty/markSyncDirty.lua b/server/src/_luaScriptsV2/syncDirty/markSyncDirty.lua new file mode 100644 index 0000000000..492c715b4d --- /dev/null +++ b/server/src/_luaScriptsV2/syncDirty/markSyncDirty.lua @@ -0,0 +1,27 @@ +-- Merge selector/snapshot fields into the customer's sync dirty hash, refresh +-- its TTL, then attempt to set the signal marker (NX + TTL). Returns 1 when the +-- marker was newly set (no signal believed in flight), else 0. +-- uw:* fields carry {ts, snapshot} JSON: an older snapshot never overwrites a +-- newer one (cross-process batches can arrive out of order). +-- KEYS[1] dirty hash, KEYS[2] signal marker +-- ARGV[1] signal TTL seconds, ARGV[2] dirty TTL seconds, ARGV[3..] field/value pairs +local dirtyKey = KEYS[1] +local signalKey = KEYS[2] +local signalTtl = tonumber(ARGV[1]) +local dirtyTtl = tonumber(ARGV[2]) +for i = 3, #ARGV, 2 do + local field = ARGV[i] + local value = ARGV[i + 1] + if string.sub(field, 1, 3) == 'uw:' then + local existing = redis.call('HGET', dirtyKey, field) + if not existing or cjson.decode(value).ts >= cjson.decode(existing).ts then + redis.call('HSET', dirtyKey, field, value) + end + else + redis.call('HSET', dirtyKey, field, value) + end +end +redis.call('EXPIRE', dirtyKey, dirtyTtl) +local set = redis.call('SET', signalKey, '1', 'NX', 'EX', signalTtl) +if set then return 1 end +return 0 diff --git a/server/src/cron.ts b/server/src/cron.ts index 2f7278956c..49185408bf 100644 --- a/server/src/cron.ts +++ b/server/src/cron.ts @@ -7,7 +7,10 @@ await warmupRegionalRedis(); // Edge config modules self-register on import (cron reads redis-v2-cache // so resolveRedisV2 picks the right instance on each ctx build). await import("./internal/misc/redisV2Cache/redisV2CacheStore.js"); +await import("./internal/misc/mainRedisCache/mainRedisCacheStore.js"); await import("./internal/misc/cacheV2Ramp/cacheV2RampStore.js"); +await import("./internal/misc/resetJob/resetJobStore.js"); +await import("./internal/misc/resetJobV2/resetJobV2Store.js"); const { logger } = await import("./external/logtail/logtailUtils.js"); const { startAllEdgeConfigPolling } = await import( "./internal/misc/edgeConfig/edgeConfigRegistry.js" diff --git a/server/src/cron/cronInit.ts b/server/src/cron/cronInit.ts index c2054a86af..a4df2a5091 100644 --- a/server/src/cron/cronInit.ts +++ b/server/src/cron/cronInit.ts @@ -4,6 +4,8 @@ import { initDrizzle } from "../db/initDrizzle.js"; import { startPgPoolMonitor, stopPgPoolMonitor } from "../db/pgPoolMonitor.js"; import { runDbProbes } from "../db/probes/runDbProbes.js"; import { logger } from "../external/logtail/logtailUtils.js"; +import { runResetLoopV2 } from "../internal/balances/batchReset/runResetLoopV2.js"; +import { stopAllEdgeConfigPolling } from "../internal/misc/edgeConfig/edgeConfigRegistry.js"; import { describeSlotGate, isActiveSlot, @@ -17,7 +19,7 @@ import { runInvoiceCron } from "./invoiceCron/runInvoiceCron.js"; import { runOneOffCleanup } from "./oneoffCron/runOneOffCleanup.js"; import { runOneOffExpiry } from "./oneoffCron/runOneOffExpiry.js"; import { runProductCron } from "./productCron/runProductCron.js"; -import { runResetCron } from "./resetCron/runResetCron.js"; +import { runResetLoop } from "./resetCron/runResetLoop.js"; import { runSeatSyncCron } from "./seatSyncCron/runSeatSyncCron.js"; import type { CronContext } from "./utils/CronContext.js"; @@ -30,6 +32,9 @@ const { db: probeDb, client: probeClient } = initDrizzle({ startPgPoolMonitor(); startBlueGreenHeartbeat({ db, logger, serviceName: "cron" }); +const ctx: CronContext = { db, logger }; +let shuttingDown = false; + const logCronHeartbeat = (job = "main") => { logger.info( { @@ -45,6 +50,8 @@ const logCronHeartbeat = (job = "main") => { }; const shouldRunTick = () => { + if (shuttingDown) return false; + if (process.env.DISABLE_CRON === "true") { console.log(`Cron disabled!`); return false; @@ -69,13 +76,8 @@ const main = async () => { logCronHeartbeat(); - const ctx: CronContext = { - db, - logger, - }; await Promise.all([ runProductCron({ ctx }), - runResetCron({ ctx }), runInvoiceCron({ ctx }), runOneOffExpiry({ ctx }), // runClearExpiredResetCron({ ctx }), @@ -89,10 +91,6 @@ const oneOffCleanupTick = async () => { logCronHeartbeat("one_off_cleanup"); - const ctx: CronContext = { - db, - logger, - }; await Promise.all([runOneOffCleanup({ ctx }), runSeatSyncCron({ ctx })]); }; @@ -131,22 +129,32 @@ main(); oneOffCleanupTick(); dbProbesTick(); -process.on("SIGTERM", async () => { - console.log("Received SIGTERM signal, closing database connection..."); - stopPgPoolMonitor(); - stopBlueGreenHeartbeat({ serviceName: "cron" }); - stopBlueGreenSlotStorePolling({ serviceName: "cron" }); - await client.end(); - await probeClient.end(); - process.exit(0); +// V1 and V2 loops are fully independent, each gated by its own edge config +// (reset-job / reset-job-v2), so either can be flipped without a deploy. +const resetLoopController = new AbortController(); +const resetLoopPromise = runResetLoop({ + ctx, + signal: resetLoopController.signal, +}); +const resetLoopV2Promise = runResetLoopV2({ + ctx, + signal: resetLoopController.signal, }); -process.on("SIGINT", async () => { - console.log("Received SIGINT signal, closing database connection..."); +const shutdown = async (signal: string) => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`Received ${signal} signal, closing database connection...`); + resetLoopController.abort(); stopPgPoolMonitor(); stopBlueGreenHeartbeat({ serviceName: "cron" }); stopBlueGreenSlotStorePolling({ serviceName: "cron" }); + stopAllEdgeConfigPolling(); + await Promise.all([resetLoopPromise, resetLoopV2Promise]); await client.end(); await probeClient.end(); process.exit(0); -}); +}; + +process.on("SIGTERM", () => void shutdown("SIGTERM")); +process.on("SIGINT", () => void shutdown("SIGINT")); diff --git a/server/src/cron/resetCron/runResetBatch.ts b/server/src/cron/resetCron/runResetBatch.ts new file mode 100644 index 0000000000..32eceb2ab7 --- /dev/null +++ b/server/src/cron/resetCron/runResetBatch.ts @@ -0,0 +1,101 @@ +import { + type AppEnv, + type Feature, + notNullish, + type Organization, + type ResetCusEnt, +} from "@autumn/shared"; +import * as Sentry from "@sentry/bun"; +import pLimit from "p-limit"; +import { buildFullSubjectOrgEnvKey } from "@/internal/customers/cache/fullSubject/builders/buildFullSubjectOrgEnvKey.js"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; +import { getResetJobConfig } from "@/internal/misc/resetJob/resetJobStore.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import type { CronContext } from "../utils/CronContext"; +import { clearCusEntsFromCache } from "./clearCusEntsFromCache"; +import { resetCustomerEntitlement } from "./resetCustomerEntitlement"; + +const RESET_CONCURRENCY = 25; + +export const runResetBatch = async ({ ctx }: { ctx: CronContext }) => { + const { db, logger } = ctx; + const startedAt = Date.now(); + const { batchSize } = getResetJobConfig(); + const limitConcurrency = pLimit(RESET_CONCURRENCY); + const orgs = new Map(); + const cusEnts = await CusEntService.getActiveResetPassed({ + db, + batchSize, + limit: batchSize, + includeSeparateIntervalResets: false, + }); + + const uniqueOrgEnvs = new Map(); + for (const cusEnt of cusEnts) { + const { env, org_id: orgId } = cusEnt.customer; + uniqueOrgEnvs.set(buildFullSubjectOrgEnvKey({ orgId, env }), { + orgId, + env, + }); + } + + await Promise.all( + [...uniqueOrgEnvs.values()].map(({ orgId, env }) => + limitConcurrency(async () => { + try { + const org = await OrgService.getWithFeatures({ db, orgId, env }); + if (org) orgs.set(buildFullSubjectOrgEnvKey({ orgId, env }), org); + } catch (error) { + logger.error( + { jobName: "reset-cus-ents", orgId, env, err: error }, + "[reset-cus-ents] failed to load org", + ); + Sentry.captureException(error, { + extra: { orgId, env, context: "runResetBatch.getOrgWithFeatures" }, + }); + } + }), + ), + ); + + const updatedCusEnts: ResetCusEnt[] = []; + const results = await Promise.all( + cusEnts.map((cusEnt) => + limitConcurrency(async () => { + const org = orgs.get( + buildFullSubjectOrgEnvKey({ + orgId: cusEnt.customer.org_id, + env: cusEnt.customer.env, + }), + ); + if (!org) return; + + return resetCustomerEntitlement({ + ctx, + org: org.org, + cusEnt, + updatedCusEnts, + persistFreeOverage: org.org.config.persist_free_overage ?? false, + }); + }), + ), + ); + + const toUpsert = results.filter(notNullish); + if (toUpsert.length > 0) { + await CusEntService.upsert({ db, data: toUpsert }); + } + await clearCusEntsFromCache({ cusEnts: updatedCusEnts }); + + const result = { + batchSize, + fetched: cusEnts.length, + upserted: toUpsert.length, + durationMs: Date.now() - startedAt, + }; + logger.info( + { jobName: "reset-cus-ents", ...result }, + "[reset-cus-ents] batch finished", + ); + return result; +}; diff --git a/server/src/cron/resetCron/runResetCron.ts b/server/src/cron/resetCron/runResetCron.ts deleted file mode 100644 index e2b21c44cf..0000000000 --- a/server/src/cron/resetCron/runResetCron.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { - type AppEnv, - type Feature, - notNullish, - type Organization, - type ResetCusEnt, -} from "@autumn/shared"; -import { UTCDate } from "@date-fns/utc"; -import * as Sentry from "@sentry/bun"; -import { format } from "date-fns"; -import { buildFullSubjectOrgEnvKey } from "@/internal/customers/cache/fullSubject/builders/buildFullSubjectOrgEnvKey.js"; -import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; -import { OrgService } from "@/internal/orgs/OrgService.js"; -import type { CronContext } from "../utils/CronContext"; -import { clearCusEntsFromCache } from "./clearCusEntsFromCache"; -import { resetCustomerEntitlement } from "./resetCustomerEntitlement"; - -export const runResetCron = async ({ ctx }: { ctx: CronContext }) => { - const { db, logger } = ctx; - - const maxIterations = 10; - const timeoutMs = 60_000; // 1 minute - const startTime = Date.now(); - - const orgWithFeaturesCache = new Map< - string, - { org: Organization; features: Feature[] } - >(); - - const getOrgWithFeatures = async ({ - orgId, - env, - }: { - orgId: string; - env: AppEnv; - }): Promise<{ org: Organization; features: Feature[] } | undefined> => { - const cacheKey = buildFullSubjectOrgEnvKey({ orgId, env }); - const cached = orgWithFeaturesCache.get(cacheKey); - if (cached) return cached; - - try { - const orgWithFeatures = await OrgService.getWithFeatures({ - db, - orgId, - env, - }); - if (!orgWithFeatures) return undefined; - orgWithFeaturesCache.set(cacheKey, orgWithFeatures); - return orgWithFeatures; - } catch (error) { - console.error( - `Reset cron: failed to fetch org with features for orgId=${orgId}, env=${env}, skipping cusEnts for this org`, - error, - ); - - logger.error( - `Reset cron: failed to fetch org with features for orgId=${orgId}, env=${env}, skipping cusEnts for this org ${error}`, - ); - - Sentry.captureException(error, { - extra: { orgId, env, context: "runResetCron.getOrgWithFeatures" }, - }); - return undefined; - } - }; - - try { - let iteration = 0; - let totalFetched = 0; - let totalUpserted = 0; - - logger.info({ jobName: "reset-cus-ents" }, "[reset-cus-ents] started"); - - while (iteration < maxIterations && Date.now() - startTime < timeoutMs) { - iteration++; - - const fetchStartedAt = Date.now(); - const cusEnts = await CusEntService.getActiveResetPassed({ - db, - batchSize: 5_000, - limit: 5_000, - includeSeparateIntervalResets: false, - }); - totalFetched += cusEnts.length; - - logger.info( - { - jobName: "reset-cus-ents", - iteration, - fetched: cusEnts.length, - fetchDurationMs: Date.now() - fetchStartedAt, - }, - `[reset-cus-ents] iteration ${iteration}: fetched ${cusEnts.length} in ${Date.now() - fetchStartedAt}ms`, - ); - - if (cusEnts.length < 5_000) { - logger.info( - { jobName: "reset-cus-ents", fetched: cusEnts.length }, - `[reset-cus-ents] only ${cusEnts.length} entitlements to reset, skipping (lazy reset will handle)`, - ); - break; - } - - const batchSize = 1000; - for (let i = 0; i < cusEnts.length; i += batchSize) { - const batch = cusEnts.slice(i, i + batchSize); - - const uniqueOrgEnvs = new Map(); - for (const customerEntitlement of batch) { - const env = customerEntitlement.customer.env; - const orgId = customerEntitlement.customer.org_id; - uniqueOrgEnvs.set(buildFullSubjectOrgEnvKey({ orgId, env }), { - orgId, - env, - }); - } - await Promise.all( - [...uniqueOrgEnvs.values()].map(({ orgId, env }) => - getOrgWithFeatures({ orgId, env }), - ), - ); - - const batchResets = []; - const updatedCusEnts: ResetCusEnt[] = []; - for (const cusEnt of batch) { - const orgWithFeatures = orgWithFeaturesCache.get( - buildFullSubjectOrgEnvKey({ - orgId: cusEnt.customer.org_id, - env: cusEnt.customer.env, - }), - ); - if (!orgWithFeatures) continue; - - batchResets.push( - resetCustomerEntitlement({ - ctx, - org: orgWithFeatures.org, - cusEnt: cusEnt, - updatedCusEnts, - persistFreeOverage: - orgWithFeatures.org.config.persist_free_overage ?? false, - }), - ); - } - - const results = await Promise.all(batchResets); - - const toUpsert = results.filter(notNullish); - await CusEntService.upsert({ - db, - data: toUpsert, - }); - totalUpserted += toUpsert.length; - - await clearCusEntsFromCache({ - cusEnts: updatedCusEnts, - }); - } - } - - logger.info( - { - jobName: "reset-cus-ents", - iterations: iteration, - totalFetched, - totalUpserted, - durationMs: Date.now() - startTime, - }, - `[reset-cus-ents] finished: fetched ${totalFetched}, upserted ${totalUpserted} (monthly/yearly resets update in place and are not counted here) across ${iteration} iteration(s) in ${Date.now() - startTime}ms`, - ); - console.log( - "FINISHED RESET CRON:", - format(new UTCDate(), "yyyy-MM-dd HH:mm:ss"), - ); - } catch (error) { - logger.error( - { - jobName: "reset-cus-ents", - durationMs: Date.now() - startTime, - err: error, - }, - "[reset-cus-ents] failed", - ); - console.error("Error getting entitlements for reset:", error); - return; - } -}; diff --git a/server/src/cron/resetCron/runResetLoop.ts b/server/src/cron/resetCron/runResetLoop.ts new file mode 100644 index 0000000000..7dd15716ec --- /dev/null +++ b/server/src/cron/resetCron/runResetLoop.ts @@ -0,0 +1,69 @@ +import { ms } from "@autumn/shared"; +import * as Sentry from "@sentry/bun"; +import { isResetJobEnabled } from "@/internal/misc/resetJob/resetJobStore.js"; +import { isActiveSlot } from "@/queue/blueGreen/blueGreenGate.js"; +import type { CronContext } from "../utils/CronContext.js"; +import { runResetBatch } from "./runResetBatch.js"; + +const ACTIVE_DELAY_MS = ms.seconds(1); +const IDLE_DELAY_MS = ms.seconds(5); + +type ResetBatchResult = Awaited>; + +const waitForNextBatch = async ({ + delayMs, + signal, +}: { + delayMs: number; + signal: AbortSignal; +}) => { + if (signal.aborted) return; + await new Promise((resolve) => { + const finish = () => { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + }; + const timer = setTimeout(finish, delayMs); + signal.addEventListener("abort", finish, { once: true }); + }); +}; + +export const runResetLoop = async ({ + ctx, + signal, + isEnabled = isResetJobEnabled, + isActive = () => isActiveSlot({ serviceName: "cron" }), + runBatch = runResetBatch, + wait = waitForNextBatch, +}: { + ctx: CronContext; + signal: AbortSignal; + isEnabled?: () => boolean; + isActive?: () => boolean; + runBatch?: (params: { ctx: CronContext }) => Promise; + wait?: (params: { delayMs: number; signal: AbortSignal }) => Promise; +}) => { + while (!signal.aborted) { + let delayMs = IDLE_DELAY_MS; + + if (process.env.DISABLE_CRON !== "true" && isEnabled() && isActive()) { + try { + const result = await runBatch({ ctx }); + if (result.fetched === result.batchSize) { + delayMs = ACTIVE_DELAY_MS; + } + } catch (error) { + ctx.logger.error( + { jobName: "reset-cus-ents", err: error }, + "[reset-cus-ents] batch failed", + ); + Sentry.captureException(error, { + extra: { context: "runResetLoop.runBatch" }, + }); + } + } + + await wait({ delayMs, signal }); + } +}; diff --git a/server/src/cron/resetV2.ts b/server/src/cron/resetV2.ts new file mode 100644 index 0000000000..3944bf8cb0 --- /dev/null +++ b/server/src/cron/resetV2.ts @@ -0,0 +1,141 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +type ResetEnvironment = "staging" | "prod"; + +const WRAPPED_ENV_VAR = "AUTUMN_RESET_V2_WRAPPED"; +const ENV_FILES: Record = { + staging: ".env.staging", + prod: ".env.prod", +}; + +const parseEnvironment = (): ResetEnvironment => { + const environment = process.argv[2]; + if (environment === "staging" || environment === "prod") return environment; + + console.error("Usage: bun reset-v2 "); + process.exit(2); +}; + +const runWithInfisical = async ({ + environment, +}: { + environment: ResetEnvironment; +}) => { + const child = spawn( + "infisical", + [ + "run", + `--env=${environment}`, + "--recursive", + "--", + "bun", + fileURLToPath(import.meta.url), + environment, + ], + { + stdio: "inherit", + env: { + ...process.env, + [WRAPPED_ENV_VAR]: "1", + ENV_FILE: ENV_FILES[environment], + NODE_ENV: "development", + }, + }, + ); + + const exitCode = await new Promise((resolve) => { + child.on("close", (code) => resolve(code ?? 1)); + child.on("error", (error) => { + console.error(`Failed to start Infisical: ${error.message}`); + resolve(1); + }); + }); + process.exit(exitCode); +}; + +const runResetV2 = async ({ + environment, +}: { + environment: ResetEnvironment; +}) => { + await import("../sentry.js"); + await import("../internal/misc/resetJobV2/resetJobV2Store.js"); + + const { initDrizzle } = await import("../db/initDrizzle.js"); + const { startPgPoolMonitor, stopPgPoolMonitor } = await import( + "../db/pgPoolMonitor.js" + ); + const { logger } = await import("../external/logtail/logtailUtils.js"); + const { runResetLoopV2 } = await import( + "../internal/balances/batchReset/runResetLoopV2.js" + ); + const { getResetJobV2Config, getResetJobV2ConfigStatus } = await import( + "../internal/misc/resetJobV2/resetJobV2Store.js" + ); + const { startAllEdgeConfigPolling, stopAllEdgeConfigPolling } = await import( + "../internal/misc/edgeConfig/edgeConfigRegistry.js" + ); + + await startAllEdgeConfigPolling({ logger }); + + const { db, client } = initDrizzle({ + name: "reset-cron-v2", + maxConnections: 10, + }); + startPgPoolMonitor(); + + const controller = new AbortController(); + const loopPromise = runResetLoopV2({ + ctx: { db, logger }, + signal: controller.signal, + }); + let shuttingDown = false; + + // Everything under `data`: the express dataset is at its Axiom column + // limit, so new top-level (flattened) field names get the whole ingest + // batch rejected. + logger.info("[reset-cus-ents-v2] dedicated scanner started", { + jobName: "reset-cus-ents-v2", + data: { + environment, + config: getResetJobV2Config(), + configStatus: getResetJobV2ConfigStatus(), + }, + }); + + // The scanner must never die because telemetry hiccuped (e.g. an Axiom + // ingest rejection inside the pino transport becomes an unhandled + // rejection, which kills the process by default). + process.on("unhandledRejection", (reason) => { + console.error("[reset-cus-ents-v2] unhandled rejection (ignored):", reason); + }); + + const shutdown = async (signal: string) => { + if (shuttingDown) return; + shuttingDown = true; + logger.info(`[reset-cus-ents-v2] received ${signal}, shutting down`); + controller.abort(); + stopPgPoolMonitor(); + stopAllEdgeConfigPolling(); + await loopPromise; + await client.end(); + }; + + process.once("SIGINT", () => void shutdown("SIGINT")); + process.once("SIGTERM", () => void shutdown("SIGTERM")); + + await loopPromise; +}; + +const main = async () => { + const environment = parseEnvironment(); + if (process.env[WRAPPED_ENV_VAR] !== "1") { + await runWithInfisical({ environment }); + return; + } + + await runResetV2({ environment }); +}; + +await main(); diff --git a/server/src/db/shed503OnTransientError.ts b/server/src/db/shed503OnTransientError.ts index e2d7c44822..749cda8acb 100644 --- a/server/src/db/shed503OnTransientError.ts +++ b/server/src/db/shed503OnTransientError.ts @@ -7,10 +7,12 @@ export const shed503OnTransientError = async ({ ctx, source, run, + onTransientError, }: { ctx: AutumnContext; source: string; run: () => T | Promise; + onTransientError?: (error: unknown) => Promise; }): Promise => { try { return await run(); @@ -22,6 +24,14 @@ export const shed503OnTransientError = async ({ type: `${source}_fail_open`, error, }); + try { + await onTransientError?.(error); + } catch (recoveryError) { + ctx.logger.error( + `[${source}] Failed to capture transient error for recovery`, + { error: recoveryError }, + ); + } throw new RecaseError({ message: "Service is temporarily unavailable, please retry shortly.", code: "service_unavailable", diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 35fc321541..cf156b8ce0 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -1123,6 +1123,7 @@ export class AutumnInt { dry_run?: boolean; only?: string[]; limit?: number; + /** @deprecated Migration concurrency is fleet-managed. */ concurrency?: number; lazy_run?: boolean; retry_item_statuses?: ("failed" | "skipped")[]; diff --git a/server/src/external/aws/s3/adminS3Config.ts b/server/src/external/aws/s3/adminS3Config.ts index 274084d807..b8c9f34dac 100644 --- a/server/src/external/aws/s3/adminS3Config.ts +++ b/server/src/external/aws/s3/adminS3Config.ts @@ -10,8 +10,13 @@ export const ADMIN_RATE_LIMIT_REDIS_ALLOWLIST_CONFIG_KEY = "admin/rate-limit-redis-allowlist-config.json"; export const ADMIN_REDIS_V2_CACHE_CONFIG_KEY = "admin/redis-v2-cache-config.json"; +export const ADMIN_MAIN_REDIS_CACHE_CONFIG_KEY = + "admin/main-redis-cache-config.json"; export const ADMIN_CACHE_V2_RAMP_CONFIG_KEY = "admin/cache-v2-ramp-config.json"; export const ADMIN_JOB_QUEUE_CONFIG_KEY = "admin/job-queue-config.json"; +export const ADMIN_BATCH_RESET_CONFIG_KEY = "admin/batch-reset-config.json"; +export const ADMIN_RESET_JOB_CONFIG_KEY = "admin/reset-job-config.json"; +export const ADMIN_RESET_JOB_V2_CONFIG_KEY = "admin/reset-job-v2-config.json"; export const ADMIN_MISCELLANEOUS_EDGE_CONFIG_KEY = "admin/miscellaneous-edge-config.json"; export const ADMIN_FULL_SUBJECT_GATE_CONFIG_KEY = @@ -66,6 +71,11 @@ export const getAdminEdgeConfigSources = () => ({ label: "V2 Redis Instance", key: ADMIN_REDIS_V2_CACHE_CONFIG_KEY, }, + { + id: "main-redis-cache", + label: "Main Redis Instance", + key: ADMIN_MAIN_REDIS_CACHE_CONFIG_KEY, + }, { id: "cache-v2-ramp", label: "Cache V2 Ramp", @@ -76,6 +86,21 @@ export const getAdminEdgeConfigSources = () => ({ label: "Job Queues", key: ADMIN_JOB_QUEUE_CONFIG_KEY, }, + { + id: "batch-resets", + label: "Lazy Batch Resets", + key: ADMIN_BATCH_RESET_CONFIG_KEY, + }, + { + id: "reset-job", + label: "Reset Job", + key: ADMIN_RESET_JOB_CONFIG_KEY, + }, + { + id: "reset-job-v2", + label: "Reset Job V2", + key: ADMIN_RESET_JOB_V2_CONFIG_KEY, + }, { id: "miscellaneous", label: "Miscellaneous", diff --git a/server/src/external/redis/initRedis.ts b/server/src/external/redis/initRedis.ts index 30e3a91e47..78ceae8874 100644 --- a/server/src/external/redis/initRedis.ts +++ b/server/src/external/redis/initRedis.ts @@ -1,11 +1,18 @@ import "./initUtils/redisTypes.js"; export { + createDisabledRedis, createRedisClient, createRedisConnection, - createDisabledRedis, } from "./initUtils/createRedisClient.js"; export { + getRedisAvailability, + shouldUseRedis, + startRedisMonitor, + stopRedisMonitor, +} from "./initUtils/redisAvailability.js"; +export { + getFallbackRedis, getPrimaryRedis, getRegionalRedis, redis, @@ -15,12 +22,6 @@ export { getConfiguredRegions, hasRedisConfig, } from "./initUtils/redisConfig.js"; -export { - getRedisAvailability, - shouldUseRedis, - startRedisMonitor, - stopRedisMonitor, -} from "./initUtils/redisAvailability.js"; export { waitForRedisReady, warmupRegionalRedis, diff --git a/server/src/external/redis/initUtils/createRedisClient.ts b/server/src/external/redis/initUtils/createRedisClient.ts index 098cd4d494..28ca4e4c3a 100644 --- a/server/src/external/redis/initUtils/createRedisClient.ts +++ b/server/src/external/redis/initUtils/createRedisClient.ts @@ -1,6 +1,6 @@ import { Redis } from "ioredis"; import { instrumentRedis } from "../otel/instrumentRedis.js"; -import { cacheBackupUrl } from "./redisConfig.js"; +import { redisDnsLookup } from "./redisDnsLookup.js"; import { registerRedisCommands } from "./registerRedisCommands.js"; const REDIS_COMMAND_TIMEOUT_MS = @@ -22,11 +22,13 @@ const formatRedisEndpoint = ({ cacheUrl }: { cacheUrl: string }) => { export const createRedisClient = ({ cacheUrl, region, + cacheCert = process.env.CACHE_CERT || null, supportsUpstashShebang = false, commandTimeout = REDIS_COMMAND_TIMEOUT_MS, }: { cacheUrl: string; region: string; + cacheCert?: string | null; supportsUpstashShebang?: boolean; commandTimeout?: number; }): Redis => { @@ -34,10 +36,13 @@ export const createRedisClient = ({ `[Redis] ${region}: connecting to ${formatRedisEndpoint({ cacheUrl })}`, ); + const usesTls = cacheUrl.startsWith("rediss:"); + const instance = new Redis(cacheUrl, { - tls: - process.env.CACHE_CERT && !cacheBackupUrl - ? { ca: process.env.CACHE_CERT } + tls: cacheCert + ? { ca: cacheCert } + : usesTls + ? { lookup: redisDnsLookup } : undefined, family: 4, keepAlive: 10000, diff --git a/server/src/external/redis/initUtils/redisClientRegistry.ts b/server/src/external/redis/initUtils/redisClientRegistry.ts index 620cef5f8b..17a3e3e5a8 100644 --- a/server/src/external/redis/initUtils/redisClientRegistry.ts +++ b/server/src/external/redis/initUtils/redisClientRegistry.ts @@ -1,6 +1,12 @@ import type { Redis } from "ioredis"; +import { getActiveMainRedisInstance } from "@/internal/misc/mainRedisCache/mainRedisCacheStore.js"; +import { + createMainRedisRouter, + selectMainRedisClient, +} from "../mainRedisRouting.js"; import { createDisabledRedis, createRedisClient } from "./createRedisClient.js"; import { + cacheBackupUrl, currentRegion, getCacheUrlForRegion, hasRedisConfig, @@ -8,17 +14,19 @@ import { primaryCacheUrl, } from "./redisConfig.js"; -if (process.env.CACHE_BACKUP_URL?.trim()) { +if (cacheBackupUrl) { console.log( - `[Redis] Using CACHE_BACKUP_URL for all regions (primary region: ${currentRegion})`, + `[Redis] CACHE_BACKUP_URL configured as fallback (primary region: ${currentRegion})`, ); } else if (!hasRedisConfig) { - console.warn("[Redis] No Redis URL configured. Running in Postgres-only mode."); + console.warn( + "[Redis] No Redis URL configured. Running in Postgres-only mode.", + ); } else if (primaryCacheUrl && getCacheUrlForRegion({ region: currentRegion })) { console.log(`Using regional cache: ${currentRegion}`); } -const primaryRedis = +const localPrimaryRedis = hasRedisConfig && primaryCacheUrl ? createRedisClient({ cacheUrl: primaryCacheUrl, @@ -26,22 +34,37 @@ const primaryRedis = }) : createDisabledRedis(); -/** - * The active Redis instance. All consumer code imports this. - * Normally points to the primary (current region). - */ -export const redis: Redis = primaryRedis; +const fallbackRedis = !cacheBackupUrl + ? null + : cacheBackupUrl === primaryCacheUrl + ? localPrimaryRedis + : createRedisClient({ + cacheUrl: cacheBackupUrl, + region: `${currentRegion}:fallback`, + cacheCert: null, + }); + +export const getFallbackRedis = (): Redis | null => fallbackRedis; // Lazy-loaded regional Redis instances for cross-region sync const regionalRedisInstances: Map = new Map(); +let lastLoggedInstance: string | null = null; +let missingFallbackWarned = false; + +export const getRegionalRedisForInstance = ({ + region, + instance, +}: { + region: string; + instance: "primary" | "fallback"; +}): Redis => { + if (instance === "fallback" && fallbackRedis) return fallbackRedis; -/** Get Redis instance for a specific region (lazy-loaded) */ -export const getRegionalRedis = (region: string): Redis => { if (!hasRedisConfig) { - return primaryRedis; + return localPrimaryRedis; } if (region === currentRegion) { - return primaryRedis; + return localPrimaryRedis; } const cacheUrl = getCacheUrlForRegion({ region }); @@ -50,11 +73,11 @@ export const getRegionalRedis = (region: string): Redis => { console.warn( `No cache URL configured for region ${region}, falling back to primary`, ); - return primaryRedis; + return localPrimaryRedis; } if (cacheUrl === primaryCacheUrl) { - return primaryRedis; + return localPrimaryRedis; } let regionalInstance = regionalRedisInstances.get(region); @@ -69,5 +92,34 @@ export const getRegionalRedis = (region: string): Redis => { return regionalInstance; }; +/** Get the active Redis instance for a region. */ +export const getRegionalRedis = (region: string): Redis => { + const activeInstance = getActiveMainRedisInstance(); + if (activeInstance !== lastLoggedInstance) { + console.log(`[Redis] Active main instance: ${activeInstance}`); + lastLoggedInstance = activeInstance; + } + if ( + activeInstance === "fallback" && + !fallbackRedis && + !missingFallbackWarned + ) { + console.warn( + "[Redis] Fallback selected without CACHE_BACKUP_URL; using primary", + ); + missingFallbackWarned = true; + } + + return selectMainRedisClient({ + activeInstance, + primary: () => getRegionalRedisForInstance({ region, instance: "primary" }), + fallback: fallbackRedis, + }); +}; + +export const redis: Redis = createMainRedisRouter({ + resolve: () => getRegionalRedis(currentRegion), +}); + /** Get the primary Redis instance (us-west-2) to avoid replication lag issues */ export const getPrimaryRedis = () => getRegionalRedis(PRIMARY_REGION); diff --git a/server/src/external/redis/initUtils/redisConfig.ts b/server/src/external/redis/initUtils/redisConfig.ts index 8b8df529bd..9838ae8a98 100644 --- a/server/src/external/redis/initUtils/redisConfig.ts +++ b/server/src/external/redis/initUtils/redisConfig.ts @@ -10,29 +10,29 @@ export const currentRegion = process.env.AWS_REGION || REGION_US_WEST_2; export const cacheBackupUrl = process.env.CACHE_BACKUP_URL?.trim(); -// Map of region to cache URL. When CACHE_BACKUP_URL is set, all regions use it -// (failover / single backup endpoint). -const regionToCacheUrl: Record = cacheBackupUrl - ? { - [REGION_US_EAST_2]: cacheBackupUrl, - [REGION_US_WEST_2]: cacheBackupUrl, - } - : { - [REGION_US_EAST_2]: process.env.CACHE_URL_US_EAST, - [REGION_US_WEST_2]: process.env.CACHE_URL, - }; +// The backup is edge-selected unless it is the only configured Redis endpoint. +const regionToCacheUrl: Record = { + [REGION_US_EAST_2]: process.env.CACHE_URL_US_EAST, + [REGION_US_WEST_2]: process.env.CACHE_URL, +}; +const hasPrimaryCacheUrls = Object.values(regionToCacheUrl).some(Boolean); export const primaryCacheUrl = regionToCacheUrl[currentRegion] || process.env.CACHE_URL || cacheBackupUrl; export const hasRedisConfig = Boolean(primaryCacheUrl); + /** Get all regions that have configured cache URLs */ export const getConfiguredRegions = (): string[] => { - return ALL_REGIONS.filter((region) => regionToCacheUrl[region]); + const configured = ALL_REGIONS.filter((region) => regionToCacheUrl[region]); + return hasPrimaryCacheUrls || !cacheBackupUrl ? configured : [...ALL_REGIONS]; }; export const getCacheUrlForRegion = ({ region }: { region: string }) => { - return regionToCacheUrl[region]; + return ( + regionToCacheUrl[region] || + (!hasPrimaryCacheUrls ? cacheBackupUrl : undefined) + ); }; export const PRIMARY_REGION = REGION_US_WEST_2; diff --git a/server/src/external/redis/initUtils/redisDnsLookup.ts b/server/src/external/redis/initUtils/redisDnsLookup.ts new file mode 100644 index 0000000000..a68598ed30 --- /dev/null +++ b/server/src/external/redis/initUtils/redisDnsLookup.ts @@ -0,0 +1,56 @@ +import { + lookup as defaultLookup, + resolve4 as defaultResolve4, + type LookupAddress, + type LookupOptions, +} from "node:dns"; +import type { LookupFunction } from "node:net"; + +type LookupHost = ( + hostname: string, + options: LookupOptions, + callback: ( + error: NodeJS.ErrnoException | null, + address: string | LookupAddress[], + family: number, + ) => void, +) => void; + +type Resolve4Host = ( + hostname: string, + callback: (error: NodeJS.ErrnoException | null, addresses: string[]) => void, +) => void; + +export const createRedisDnsLookup = ({ + lookupHost = defaultLookup as LookupHost, + resolve4Host = defaultResolve4, +}: { + lookupHost?: LookupHost; + resolve4Host?: Resolve4Host; +} = {}): LookupFunction => { + return (hostname, options, callback) => { + lookupHost(hostname, options, (lookupError, address, family) => { + if (!lookupError) { + callback(null, address, family); + return; + } + + if (lookupError.code !== "ENOTFOUND" || options.all) { + callback(lookupError, "", family); + return; + } + + resolve4Host(hostname, (resolveError, addresses) => { + const resolvedAddress = addresses?.[0]; + if (resolveError || !resolvedAddress) { + callback(resolveError ?? lookupError, "", 4); + return; + } + + callback(null, resolvedAddress, 4); + }); + }); + }; +}; + +export const redisDnsLookup = createRedisDnsLookup(); diff --git a/server/src/external/redis/initUtils/redisWarmup.ts b/server/src/external/redis/initUtils/redisWarmup.ts index 3dc24bdcba..73a49dbd64 100644 --- a/server/src/external/redis/initUtils/redisWarmup.ts +++ b/server/src/external/redis/initUtils/redisWarmup.ts @@ -1,5 +1,8 @@ import type { Redis } from "ioredis"; -import { getRegionalRedis } from "./redisClientRegistry.js"; +import { + getFallbackRedis, + getRegionalRedisForInstance, +} from "./redisClientRegistry.js"; import { getConfiguredRegions } from "./redisConfig.js"; /** Wait for a Redis instance to be ready */ @@ -40,7 +43,10 @@ export const warmupRegionalRedis = async (): Promise => { const warmupPromises = regions.map(async (region) => { try { - const instance = getRegionalRedis(region); + const instance = getRegionalRedisForInstance({ + region, + instance: "primary", + }); await waitForRedisReady(instance, region); } catch (error) { console.error(`[Redis] ${region}: warmup failed -`, error); @@ -48,6 +54,15 @@ export const warmupRegionalRedis = async (): Promise => { } }); + const fallback = getFallbackRedis(); + if (fallback) { + warmupPromises.push( + waitForRedisReady(fallback, "fallback").catch((error) => { + console.error("[Redis] fallback: warmup failed -", error); + }), + ); + } + await Promise.all(warmupPromises); try { diff --git a/server/src/external/redis/mainRedisRouting.ts b/server/src/external/redis/mainRedisRouting.ts new file mode 100644 index 0000000000..0ac5129f9a --- /dev/null +++ b/server/src/external/redis/mainRedisRouting.ts @@ -0,0 +1,28 @@ +import type { Redis } from "ioredis"; +import type { MainRedisInstanceName } from "@/internal/misc/mainRedisCache/mainRedisCacheSchemas.js"; + +export const selectMainRedisClient = ({ + activeInstance, + primary, + fallback, +}: { + activeInstance: MainRedisInstanceName; + primary: () => Redis; + fallback: Redis | null; +}): Redis => (activeInstance === "fallback" && fallback ? fallback : primary()); + +export const createMainRedisRouter = ({ + resolve, +}: { + resolve: () => Redis; +}): Redis => + new Proxy({} as Redis, { + get(_target, property) { + const redis = resolve(); + const value = Reflect.get(redis, property, redis); + return typeof value === "function" ? value.bind(redis) : value; + }, + set(_target, property, value) { + return Reflect.set(resolve(), property, value); + }, + }); diff --git a/server/src/external/redis/resolveRedisV2.ts b/server/src/external/redis/resolveRedisV2.ts index e5c0c42688..1286770e6f 100644 --- a/server/src/external/redis/resolveRedisV2.ts +++ b/server/src/external/redis/resolveRedisV2.ts @@ -14,6 +14,47 @@ import { let lastLoggedInstance: RedisV2InstanceName | null = null; let publicRouteWarned = false; +export type ResolvedRedisV2InstanceName = RedisV2InstanceName | "ramp"; + +export const getRedisV2ByInstanceName = (name: string): Redis | null => { + if (name === "dragonfly") return redisV2Primary; + if (name === "ramp") return getRampDestinationRedis(); + if (name === "upstash" || name === "redis") { + return getAlternateRedisV2Instance(name); + } + return null; +}; + +const resolveRedisV2InstanceName = ({ + activeInstance, + customerId, +}: { + activeInstance: RedisV2InstanceName; + customerId?: string; +}): ResolvedRedisV2InstanceName => { + if (activeInstance !== "dragonfly") { + return getAlternateRedisV2Instance(activeInstance) + ? activeInstance + : "dragonfly"; + } + + if ( + isCacheV2RampEnabled({ customerId }) && + getRampDestinationRedis() + ) { + return "ramp"; + } + return "dragonfly"; +}; + +export const getCurrentRedisV2InstanceName = (opts?: { + customerId?: string; +}): ResolvedRedisV2InstanceName => + resolveRedisV2InstanceName({ + activeInstance: getActiveRedisV2Instance(), + customerId: opts?.customerId, + }); + /** Returns the V2 Redis instance for this request. * * Routing order: @@ -36,20 +77,20 @@ export const resolveRedisV2 = (opts?: { customerId?: string }): Redis => { lastLoggedInstance = activeInstance; } - if (activeInstance === "dragonfly") { - if (isCacheV2RampEnabled({ customerId: opts?.customerId })) { - const destination = getRampDestinationRedis(); - if (destination) return destination; - if (!publicRouteWarned) { - publicRouteWarned = true; - logger.warn( - "[resolveRedisV2] cache V2 ramp enabled but destination is not configured (or decryption failed); falling back to primary", - ); - } - } - return redisV2Primary; + const instanceName = resolveRedisV2InstanceName({ + activeInstance, + customerId: opts?.customerId, + }); + if ( + activeInstance === "dragonfly" && + instanceName === "dragonfly" && + isCacheV2RampEnabled({ customerId: opts?.customerId }) && + !publicRouteWarned + ) { + publicRouteWarned = true; + logger.warn( + "[resolveRedisV2] cache V2 ramp enabled but destination is not configured (or decryption failed); falling back to primary", + ); } - - const alternate = getAlternateRedisV2Instance(activeInstance); - return alternate ?? redisV2Primary; + return getRedisV2ByInstanceName(instanceName) ?? redisV2Primary; }; diff --git a/server/src/external/stripe/common/autumnStripeIdempotency.ts b/server/src/external/stripe/common/autumnStripeIdempotency.ts new file mode 100644 index 0000000000..a5d7db664f --- /dev/null +++ b/server/src/external/stripe/common/autumnStripeIdempotency.ts @@ -0,0 +1,32 @@ +import { randomUUID } from "node:crypto"; +import type Stripe from "stripe"; + +export const AUTUMN_STRIPE_IDEMPOTENCY_PREFIX = "autumn:"; + +/** Unique per call — Stripe rejects a reused key on a different request. */ +export const buildAutumnStripeIdempotencyKey = ({ + source, +}: { + source?: string; +}): string => + `${AUTUMN_STRIPE_IDEMPOTENCY_PREFIX}${source ?? "billing"}:${randomUUID()}`; + +export const autumnStripeRequestOptions = ({ + source, +}: { + source?: string; +} = {}): Stripe.RequestOptions => ({ + idempotencyKey: buildAutumnStripeIdempotencyKey({ source }), +}); + +/** True when the event was caused by an Autumn API call (vs external/automatic). */ +export const isAutumnOriginatedStripeEvent = ({ + event, +}: { + event: Stripe.Event; +}): boolean => + Boolean( + event.request?.idempotency_key?.startsWith( + AUTUMN_STRIPE_IDEMPOTENCY_PREFIX, + ), + ); diff --git a/server/src/external/stripe/common/simulateTestWebhookFailure.ts b/server/src/external/stripe/common/simulateTestWebhookFailure.ts new file mode 100644 index 0000000000..1a7b4a5d5b --- /dev/null +++ b/server/src/external/stripe/common/simulateTestWebhookFailure.ts @@ -0,0 +1,92 @@ +import { tryCatch } from "@autumn/shared"; +import type Stripe from "stripe"; +import type { StripeWebhookContext } from "../webhookMiddlewares/stripeWebhookContext.js"; + +export const TEST_WEBHOOK_FAIL_METADATA_KEY = "test_webhook_fail"; + +type EventObjectWithMetadata = { + id?: string; + object?: string; + metadata?: Record | null; + customer?: string | { id: string } | null; +}; + +/** Event payloads are frozen snapshots — the live object decides recovery. */ +const retrieveLiveMetadata = async ({ + stripeCli, + object, +}: { + stripeCli: Stripe; + object: EventObjectWithMetadata; +}): Promise | null | undefined> => { + if (!object.id) return object.metadata; + + switch (object.object) { + case "checkout.session": { + const session = await stripeCli.checkout.sessions.retrieve(object.id); + return session.metadata; + } + case "subscription": { + const subscription = await stripeCli.subscriptions.retrieve(object.id); + return subscription.metadata; + } + case "invoice": { + const invoice = await stripeCli.invoices.retrieve(object.id); + return invoice.metadata; + } + default: + return object.metadata; + } +}; + +const retrieveLiveCustomerMetadata = async ({ + stripeCli, + object, +}: { + stripeCli: Stripe; + object: EventObjectWithMetadata; +}): Promise | null | undefined> => { + const customerId = + typeof object.customer === "string" ? object.customer : object.customer?.id; + if (!customerId) return object.metadata; + + const customer = await stripeCli.customers.retrieve(customerId); + return customer.deleted ? undefined : customer.metadata; +}; + +/** + * Non-prod failure injection: an event whose Stripe object carries + * `test_webhook_fail` (pass it via the billing API's `metadata` param) fails + * EVERY delivery until the marker is removed from the live decision object — + * remove it and the next delivery succeeds. Deterministic outage simulation. + * + * Marker value picks the live decision object: "customer" → the Stripe + * customer's metadata (toggling it emits no self-healing events for the + * marked object); anything else → the marked object itself. + */ +export const throwOnSimulatedWebhookFailure = async ({ + ctx, +}: { + ctx: StripeWebhookContext; +}) => { + if (process.env.NODE_ENV === "production") return; + + const event = ctx.stripeEvent; + const object = event.data.object as EventObjectWithMetadata; + const markerValue = object?.metadata?.[TEST_WEBHOOK_FAIL_METADATA_KEY]; + if (!markerValue) return; + + // Re-fetch failing open: a broken test helper must never wedge processing. + const { data: liveMetadata, error } = await tryCatch( + markerValue === "customer" + ? retrieveLiveCustomerMetadata({ stripeCli: ctx.stripeCli, object }) + : retrieveLiveMetadata({ stripeCli: ctx.stripeCli, object }), + ); + if (error) return; + + if (liveMetadata?.[TEST_WEBHOOK_FAIL_METADATA_KEY]) { + throw new Error( + `Simulated webhook failure for ${event.type} ${event.id} — remove ${TEST_WEBHOOK_FAIL_METADATA_KEY} metadata from the live ${markerValue === "customer" ? "customer" : object.object} to let it pass`, + ); + } +}; diff --git a/server/src/external/stripe/coupons/resolvePromotionCode.ts b/server/src/external/stripe/coupons/resolvePromotionCode.ts index e45f8eb715..16f31d5af8 100644 --- a/server/src/external/stripe/coupons/resolvePromotionCode.ts +++ b/server/src/external/stripe/coupons/resolvePromotionCode.ts @@ -41,7 +41,7 @@ export const resolvePromotionCode = async ({ code, active: true, limit: 1, - expand: ["data.promotion.coupon"], + expand: ["data.promotion.coupon.applies_to"], }); if (promos.data.length === 0) { @@ -67,12 +67,9 @@ export const resolvePromotionCode = async ({ } const minimumAmountsByCurrency = Object.fromEntries( - Object.entries( - promo.restrictions?.currency_options ?? {}, - ).map(([currency, option]) => [ - currency.toLowerCase(), - option.minimum_amount, - ]), + Object.entries(promo.restrictions?.currency_options ?? {}).map( + ([currency, option]) => [currency.toLowerCase(), option.minimum_amount], + ), ); const hasCurrencyOptions = Object.keys(minimumAmountsByCurrency).length > 0; diff --git a/server/src/external/stripe/handleStripeWebhookEvent.ts b/server/src/external/stripe/handleStripeWebhookEvent.ts index bab567bf6c..e60765c84a 100644 --- a/server/src/external/stripe/handleStripeWebhookEvent.ts +++ b/server/src/external/stripe/handleStripeWebhookEvent.ts @@ -2,23 +2,10 @@ import * as Sentry from "@sentry/bun"; import type { Context } from "hono"; import { Stripe } from "stripe"; -import { handleStripeInvoicePaid } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/handleStripeInvoicePaid.js"; -import { handleStripeSubscriptionUpdated } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.js"; import { unsetOrgStripeKeys } from "@/internal/orgs/orgUtils.js"; import { handleWebhookErrorSkip } from "@/utils/routerUtils/webhookErrorSkip.js"; import { getSentryTags } from "../sentry/sentryUtils.js"; -import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js"; -import { handleStripeCustomerUpdated } from "./webhookHandlers/handleStripeCustomerUpdated.js"; -import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js"; -import { handleStripeCheckoutSessionCompleted } from "./webhookHandlers/handleStripeCheckoutSessionCompleted/handleStripeCheckoutSessionCompleted.js"; -import { handleStripeCheckoutSessionExpired } from "./webhookHandlers/handleStripeCheckoutSessionExpired/handleStripeCheckoutSessionExpired.js"; -import { handleStripeInvoiceCreated } from "./webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.js"; -import { handleStripeInvoiceFinalized } from "./webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.js"; -import { handleStripeSubscriptionDeleted } from "./webhookHandlers/handleStripeSubscriptionDeleted/handleStripeSubscriptionDeleted.js"; -import { handleStripeSubscriptionCreated } from "./webhookHandlers/handleStripeSubscriptionCreated/handleStripeSubscriptionCreated.js"; -import { handleStripeTestClockReady } from "./webhookHandlers/handleStripeTestClockReady.js"; -import { handleSubscriptionScheduleCanceled } from "./webhookHandlers/handleSubScheduleCanceled.js"; -import { handleStripeSubscriptionScheduleUpdated } from "./webhookHandlers/handleStripeSubscriptionScheduleUpdated/handleStripeSubscriptionScheduleUpdated.js"; +import { runStripeWebhookHandlers } from "./runStripeWebhookHandlers.js"; import type { StripeWebhookContext, StripeWebhookHonoEnv, @@ -36,78 +23,7 @@ export const handleStripeWebhookEvent = async ( const event = stripeEvent; try { - switch (event.type) { - case "customer.updated": - await handleStripeCustomerUpdated({ ctx, event }); - break; - - case "customer.subscription.created": - await handleStripeSubscriptionCreated({ ctx }); - break; - - case "customer.subscription.updated": - await handleStripeSubscriptionUpdated({ ctx, event }); - break; - - case "customer.subscription.deleted": - await handleStripeSubscriptionDeleted({ ctx, event }); - break; - - case "invoice.paid": - await handleStripeInvoicePaid({ ctx, event }); - break; - - case "invoice.updated": - await handleInvoiceUpdated({ - ctx, - event, - }); - break; - - case "invoice.created": - await handleStripeInvoiceCreated({ ctx, event }); - break; - - case "invoice.finalized": { - await handleStripeInvoiceFinalized({ ctx, event }); - break; - } - - case "subscription_schedule.updated": { - await handleStripeSubscriptionScheduleUpdated({ ctx, event }); - break; - } - - case "subscription_schedule.canceled": { - const canceledSchedule = event.data.object; - await handleSubscriptionScheduleCanceled({ - db, - org, - env, - schedule: canceledSchedule, - }); - break; - } - - case "customer.discount.deleted": - await handleCusDiscountDeleted({ ctx }); - break; - - case "checkout.session.completed": { - await handleStripeCheckoutSessionCompleted({ ctx, event }); - break; - } - - case "checkout.session.expired": { - await handleStripeCheckoutSessionExpired({ ctx, event }); - break; - } - - case "test_helpers.test_clock.ready": { - await handleStripeTestClockReady({ ctx, event }); - break; - } - } + await runStripeWebhookHandlers({ ctx }); } catch (error) { Sentry.captureException(error, { tags: getSentryTags({ @@ -142,10 +58,14 @@ export const handleStripeWebhookEvent = async ( } const shouldSkip = handleWebhookErrorSkip({ error, logger }); - if (!shouldSkip) { - logger.error(`Stripe webhook error: ${error}`, { error }); + if (shouldSkip) { + return c.json({ message: "Webhook received, skipped known error" }, 200); } - return c.json({ message: "Webhook received, internal server error" }, 200); + + logger.error(`Stripe webhook error: ${error}`, { error }); + // Rethrow so the ack middleware owns the outcome: sync events 500 (Stripe + // retries), early events release the idempotency lock for manual replay. + throw error; } // Note: Cache refresh is now handled by stripeWebhookRefreshMiddleware diff --git a/server/src/external/stripe/runStripeWebhookHandlers.ts b/server/src/external/stripe/runStripeWebhookHandlers.ts new file mode 100644 index 0000000000..24b9142bfb --- /dev/null +++ b/server/src/external/stripe/runStripeWebhookHandlers.ts @@ -0,0 +1,106 @@ +import { handleStripeInvoicePaid } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/handleStripeInvoicePaid.js"; +import { handleStripeSubscriptionUpdated } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.js"; +import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js"; +import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js"; +import { handleStripeCheckoutSessionCompleted } from "./webhookHandlers/handleStripeCheckoutSessionCompleted/handleStripeCheckoutSessionCompleted.js"; +import { handleStripeCheckoutSessionExpired } from "./webhookHandlers/handleStripeCheckoutSessionExpired/handleStripeCheckoutSessionExpired.js"; +import { handleStripeCustomerUpdated } from "./webhookHandlers/handleStripeCustomerUpdated.js"; +import { handleStripeInvoiceCreated } from "./webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.js"; +import { handleStripeInvoiceFinalized } from "./webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.js"; +import { handleStripeSubscriptionCreated } from "./webhookHandlers/handleStripeSubscriptionCreated/handleStripeSubscriptionCreated.js"; +import { handleStripeSubscriptionDeleted } from "./webhookHandlers/handleStripeSubscriptionDeleted/handleStripeSubscriptionDeleted.js"; +import { handleStripeSubscriptionScheduleUpdated } from "./webhookHandlers/handleStripeSubscriptionScheduleUpdated/handleStripeSubscriptionScheduleUpdated.js"; +import { handleStripeTestClockReady } from "./webhookHandlers/handleStripeTestClockReady.js"; +import { handleSubscriptionScheduleCanceled } from "./webhookHandlers/handleSubScheduleCanceled.js"; +import type { StripeWebhookContext } from "./webhookMiddlewares/stripeWebhookContext.js"; + +/** + * Dispatches a Stripe event to its handler. Shared by the webhook route and + * the SQS replay worker — errors propagate to the caller. + */ +export const runStripeWebhookHandlers = async ({ + ctx, +}: { + ctx: StripeWebhookContext; +}) => { + const { db, org, env, stripeEvent } = ctx; + const event = stripeEvent; + + // await throwOnSimulatedWebhookFailure({ ctx }); + + switch (event.type) { + case "customer.updated": + await handleStripeCustomerUpdated({ ctx, event }); + break; + + case "customer.subscription.created": + await handleStripeSubscriptionCreated({ ctx }); + break; + + case "customer.subscription.updated": + await handleStripeSubscriptionUpdated({ ctx, event }); + break; + + case "customer.subscription.deleted": + await handleStripeSubscriptionDeleted({ ctx, event }); + break; + + case "invoice.paid": + await handleStripeInvoicePaid({ ctx, event }); + break; + + case "invoice.updated": + await handleInvoiceUpdated({ + ctx, + event, + }); + break; + + case "invoice.created": + await handleStripeInvoiceCreated({ ctx, event }); + break; + + case "invoice.finalized": { + await handleStripeInvoiceFinalized({ ctx, event }); + break; + } + + case "subscription_schedule.updated": { + await handleStripeSubscriptionScheduleUpdated({ ctx, event }); + break; + } + + case "subscription_schedule.canceled": { + const canceledSchedule = event.data.object; + await handleSubscriptionScheduleCanceled({ + db, + org, + env, + schedule: canceledSchedule, + }); + break; + } + + case "customer.discount.deleted": + await handleCusDiscountDeleted({ ctx }); + break; + + case "checkout.session.completed": { + await handleStripeCheckoutSessionCompleted({ ctx, event }); + break; + } + + case "checkout.session.expired": { + await handleStripeCheckoutSessionExpired({ ctx, event }); + break; + } + + case "test_helpers.test_clock.ready": { + await handleStripeTestClockReady({ ctx, event }); + break; + } + + default: + break; + } +}; diff --git a/server/src/external/stripe/stripeWebhookRouter.ts b/server/src/external/stripe/stripeWebhookRouter.ts index 38d34d3a9e..ba63494cf7 100644 --- a/server/src/external/stripe/stripeWebhookRouter.ts +++ b/server/src/external/stripe/stripeWebhookRouter.ts @@ -7,8 +7,8 @@ import { stripeIdempotencyMiddleware } from "./webhookMiddlewares/stripeIdempote import { stripeLegacySeederMiddleware } from "./webhookMiddlewares/stripeLegacySeederMiddleware.js"; import { stripeSyncMiddleware } from "./webhookMiddlewares/stripeSyncMiddleware.js"; import { stripeToAutumnCustomerMiddleware } from "./webhookMiddlewares/stripeToAutumnCustomerMiddleware.js"; +import { stripeWebhookAckMiddleware } from "./webhookMiddlewares/stripeWebhookAckMiddleware.js"; import type { StripeWebhookHonoEnv } from "./webhookMiddlewares/stripeWebhookContext.js"; -import { stripeWebhookEarlyAckMiddleware } from "./webhookMiddlewares/stripeWebhookEarlyAckMiddleware.js"; import { stripeWebhookRefreshMiddleware } from "./webhookMiddlewares/stripeWebhookRefreshMiddleware.js"; export const stripeWebhookRouter = new Hono(); @@ -19,7 +19,7 @@ stripeWebhookRouter.post( stripeLegacySeederMiddleware, stripeToAutumnCustomerMiddleware, stripeIdempotencyMiddleware, - stripeWebhookEarlyAckMiddleware, + stripeWebhookAckMiddleware, stripeWebhookRefreshMiddleware, stripeSyncMiddleware, stripeLoggerMiddleware, @@ -33,7 +33,7 @@ stripeWebhookRouter.post( stripeConnectSeederMiddleware, stripeToAutumnCustomerMiddleware, stripeIdempotencyMiddleware, - stripeWebhookEarlyAckMiddleware, + stripeWebhookAckMiddleware, stripeWebhookRefreshMiddleware, stripeSyncMiddleware, stripeLoggerMiddleware, diff --git a/server/src/external/stripe/webhookHandlers/common/expireAndActivateWithTracking.ts b/server/src/external/stripe/webhookHandlers/common/expireAndActivateWithTracking.ts index bcb1ca9232..1ea50d8639 100644 --- a/server/src/external/stripe/webhookHandlers/common/expireAndActivateWithTracking.ts +++ b/server/src/external/stripe/webhookHandlers/common/expireAndActivateWithTracking.ts @@ -12,12 +12,7 @@ type SubscriptionEventContext = | StripeSubscriptionUpdatedContext | StripeSubscriptionDeletedContext; -/** - * Expires a customer product and activates a free successor (scheduled or default). - * Handles all tracking for updates and insertions. - * - * @returns The expired customer product (with updates applied) - */ +/** Expires a product, activates its free successor, and records both transitions. */ export const expireAndActivateWithTracking = async ({ ctx, eventContext, @@ -26,7 +21,11 @@ export const expireAndActivateWithTracking = async ({ ctx: StripeWebhookContext; eventContext: SubscriptionEventContext; customerProduct: FullCusProduct; -}): Promise<{ expiredCustomerProduct: FullCusProduct }> => { +}): Promise<{ + expiredCustomerProduct: FullCusProduct; + activatedCustomerProduct?: FullCusProduct; + insertedCustomerProduct?: FullCusProduct; +}> => { const { fullCustomer } = eventContext; const { updates, activatedCustomerProduct, insertedCustomerProduct } = @@ -60,5 +59,9 @@ export const expireAndActivateWithTracking = async ({ }); } - return { expiredCustomerProduct }; + return { + expiredCustomerProduct, + activatedCustomerProduct, + insertedCustomerProduct, + }; }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/resetSubscriptionPooledBalances.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/resetSubscriptionPooledBalances.ts index 8496b702c6..dbc1a1716f 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/resetSubscriptionPooledBalances.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/resetSubscriptionPooledBalances.ts @@ -5,14 +5,7 @@ import { } from "@autumn/shared"; import type { InvoiceCreatedContext } from "@/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.js"; import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext.js"; -import { resetCusEnts } from "@/internal/balances/utils/sql/client.js"; -import { applyResetResults } from "@/internal/customers/actions/resetCustomerEntitlements/applyResetResults.js"; -import { - type ProcessResetResult, - processReset, -} from "@/internal/customers/actions/resetCustomerEntitlements/processReset.js"; -import { processResetResultToResetCusEntParam } from "@/internal/customers/actions/resetCustomerEntitlements/processResetResultToResetCusEntParam.js"; -import { invalidateCachedFullSubject } from "@/internal/customers/cache/fullSubject/index.js"; +import { resetPooledBalances } from "@/internal/billing/v2/pooledBalances/execute/resetPooledBalances.js"; export const resetSubscriptionPooledBalances = async ({ ctx, @@ -26,9 +19,7 @@ export const resetSubscriptionPooledBalances = async ({ const pooledCustomerEntitlements = eventContext.fullCustomer.pooled_customer_entitlements ?? []; - const invoicePeriodEndMs = secondsToMs( - eventContext.stripeInvoice.period_end, - ); + const invoicePeriodEndMs = secondsToMs(eventContext.stripeInvoice.period_end); const resettablePooledCustomerEntitlements = pooledCustomerEntitlements.filter( (customerEntitlement) => @@ -44,42 +35,10 @@ export const resetSubscriptionPooledBalances = async ({ if (resettablePooledCustomerEntitlements.length === 0) return; - const computed: Array<{ - cusEntId: string; - result: ProcessResetResult; - }> = []; - - for (const customerEntitlement of resettablePooledCustomerEntitlements) { - const result = await processReset({ - ctx, - cusEnt: { ...customerEntitlement, customer_product: null }, - }); - if (result) computed.push({ cusEntId: customerEntitlement.id, result }); - } - - if (computed.length === 0) return; - - const resets = computed.map(({ cusEntId, result }) => - processResetResultToResetCusEntParam({ - customerEntitlementId: cusEntId, - result, - }), - ); - const { applied, skipped } = await resetCusEnts({ ctx, resets }); - - await applyResetResults({ + await resetPooledBalances({ ctx, - fullCus: eventContext.fullCustomer, - computed, - skipped, + fullCustomer: eventContext.fullCustomer, + pooledCustomerEntitlements: resettablePooledCustomerEntitlements, + source: "invoice-created-pooled-balance-reset", }); - - if (Object.keys(applied).length > 0) { - await invalidateCachedFullSubject({ - ctx, - customerId: - eventContext.fullCustomer.id ?? eventContext.fullCustomer.internal_id, - source: "invoice-created-pooled-balance-reset", - }); - } }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts index db09f57693..91c751dcc4 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts @@ -6,6 +6,7 @@ import { isCustomerProductScheduled, } from "@autumn/shared"; import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; +import { applyPooledBalanceCustomerProductTransitions } from "@/internal/billing/v2/pooledBalances/execute/applyPooledBalanceCustomerProductTransitions"; import { customerProductActions } from "@/internal/customers/cusProducts/actions"; import { deleteScheduledCustomerProduct } from "@/internal/customers/cusProducts/actions/deleteScheduledCustomerProduct"; import { @@ -30,6 +31,8 @@ export const expireAndActivateCustomerProducts = async ({ ); const expiredCustomerProducts: FullCusProduct[] = []; + const outgoingCustomerProducts: FullCusProduct[] = []; + const incomingCustomerProducts: FullCusProduct[] = []; const liveCustomerProducts = customerProducts.filter( (customerProduct) => !isCustomerProductScheduled(customerProduct), ); @@ -43,13 +46,24 @@ export const expireAndActivateCustomerProducts = async ({ if (!onStripeSubscription) continue; // 2. Expire and activate free successor (with tracking) - const { expiredCustomerProduct } = await expireAndActivateWithTracking({ + const { + expiredCustomerProduct, + activatedCustomerProduct, + insertedCustomerProduct, + } = await expireAndActivateWithTracking({ ctx, eventContext, customerProduct, }); expiredCustomerProducts.push(expiredCustomerProduct); + outgoingCustomerProducts.push(customerProduct); + if (activatedCustomerProduct) { + incomingCustomerProducts.push(activatedCustomerProduct); + } + if (insertedCustomerProduct) { + incomingCustomerProducts.push(insertedCustomerProduct); + } // 3. Delete paid scheduled customer product for this group if it exists... const scheduledCustomerProduct = findMainScheduledCustomerProductByGroup({ @@ -75,6 +89,14 @@ export const expireAndActivateCustomerProducts = async ({ } } + await applyPooledBalanceCustomerProductTransitions({ + ctx, + fullCustomer, + outgoingCustomerProducts, + incomingCustomerProducts, + now: eventContext.nowMs, + }); + // invoice.created needs the expired snapshots for final usage billing. await customerProductActions.expiredCache.set({ stripeSubscriptionId: stripeSubscription.id, diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts index f3e63edf3a..33b0289676 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts @@ -4,6 +4,7 @@ import { syncAutumnSubscription } from "@/external/stripe/webhookHandlers/handle import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js"; import { emitBillingChangeWebhook, logCustomerProductUpdates } from "../common"; import { setupStripeSubscriptionUpdatedContext } from "./setupStripeSubscriptionUpdatedContext.js"; +import { applyPooledBalanceTransitions } from "./tasks/applyPooledBalanceTransitions"; import { autoSyncUpdatedSubscription } from "./tasks/autoSyncUpdatedSubscription.js"; import { handleCancelOnPastDue } from "./tasks/handleCancelOnPastDue.js"; import { handleIgnorePastDue } from "./tasks/handleIgnorePastDue.js"; @@ -83,6 +84,10 @@ export const handleStripeSubscriptionUpdated = async ({ ctx, subscriptionUpdatedContext, }); + await applyPooledBalanceTransitions({ + ctx, + eventContext: subscriptionUpdatedContext, + }); // 7. Log all customer product updates logCustomerProductUpdates({ diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/applyPooledBalanceTransitions.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/applyPooledBalanceTransitions.ts new file mode 100644 index 0000000000..5a2328e8d2 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/applyPooledBalanceTransitions.ts @@ -0,0 +1,48 @@ +import { + CusProductStatus, + customerProductHasActiveStatus, + type FullCusProduct, +} from "@autumn/shared"; +import { applyPooledBalanceCustomerProductTransitions } from "@/internal/billing/v2/pooledBalances/execute/applyPooledBalanceCustomerProductTransitions"; +import type { StripeWebhookContext } from "../../../webhookMiddlewares/stripeWebhookContext"; +import type { StripeSubscriptionUpdatedContext } from "../stripeSubscriptionUpdatedContext"; + +export const applyPooledBalanceTransitions = async ({ + ctx, + eventContext, +}: { + ctx: StripeWebhookContext; + eventContext: StripeSubscriptionUpdatedContext; +}) => { + const outgoingCustomerProducts = new Map(); + const incomingCustomerProducts = new Map(); + + for (const { + customerProduct, + updates, + } of eventContext.updatedCustomerProducts) { + if (updates.status === CusProductStatus.Expired) { + outgoingCustomerProducts.set(customerProduct.id, customerProduct); + } + if (updates.status === CusProductStatus.Active) { + incomingCustomerProducts.set(customerProduct.id, { + ...customerProduct, + ...updates, + } as FullCusProduct); + } + } + + for (const customerProduct of eventContext.insertedCustomerProducts) { + if (customerProductHasActiveStatus(customerProduct)) { + incomingCustomerProducts.set(customerProduct.id, customerProduct); + } + } + + await applyPooledBalanceCustomerProductTransitions({ + ctx, + fullCustomer: eventContext.fullCustomer, + outgoingCustomerProducts: Array.from(outgoingCustomerProducts.values()), + incomingCustomerProducts: Array.from(incomingCustomerProducts.values()), + now: eventContext.nowMs, + }); +}; diff --git a/server/src/external/stripe/webhookMiddlewares/classifyStripeWebhookAckMode.ts b/server/src/external/stripe/webhookMiddlewares/classifyStripeWebhookAckMode.ts new file mode 100644 index 0000000000..55478b04b1 --- /dev/null +++ b/server/src/external/stripe/webhookMiddlewares/classifyStripeWebhookAckMode.ts @@ -0,0 +1,69 @@ +import type Stripe from "stripe"; +import { isAutumnOriginatedStripeEvent } from "@/external/stripe/common/autumnStripeIdempotency.js"; +import { isAutumnManagedSubscriptionMetadata } from "@/internal/billing/v2/providers/stripe/utils/common/autumnStripeMetadata.js"; + +export type StripeWebhookAckMode = "early" | "sync"; + +/** + * Decides whether a Stripe webhook is acked before processing ("early") or only + * after processing succeeds ("sync", so a failure 500s and Stripe retries). + * Sync is reserved for events whose handler is load-bearing: external changes + * Autumn didn't cause, plus deferred-checkout / period-boundary work that runs + * ONLY in the webhook. Early-ack keeps Autumn-triggered events from blocking + * the billing actions that emitted them. + */ +export const classifyStripeWebhookAckMode = ({ + event, + now = Date.now(), +}: { + event: Stripe.Event | undefined; + now?: number; +}): StripeWebhookAckMode => { + if (!event) return "early"; + + switch (event.type) { + // Sole executor of paid-product activation / abandoned-checkout cleanup. + case "checkout.session.completed": + case "checkout.session.expired": + return "sync"; + + // Pure external->Autumn mirror (name/email); cheap and always external. + case "customer.updated": + return "sync"; + + // Deferred billing plan execution (invoice-mode checkout, 3DS completion). + case "invoice.paid": { + const invoice = event.data.object; + return invoice.metadata?.autumn_metadata_id ? "sync" : "early"; + } + + // Vercel marketplace submission happens only in this webhook. + case "invoice.finalized": { + const invoice = event.data.object; + return invoice.metadata?.vercel_installation_id ? "sync" : "early"; + } + + // Cycle invoices own period-boundary balance resets + arrear billing. + case "invoice.created": { + const invoice = event.data.object; + return invoice.billing_reason === "subscription_cycle" ? "sync" : "early"; + } + + case "customer.subscription.created": + case "customer.subscription.updated": + case "customer.subscription.deleted": { + if (isAutumnOriginatedStripeEvent({ event })) return "early"; + + const subscription = event.data.object; + const { skip } = isAutumnManagedSubscriptionMetadata({ + metadata: subscription.metadata, + now, + requireRecent: event.type !== "customer.subscription.created", + }); + return skip ? "early" : "sync"; + } + + default: + return "early"; + } +}; diff --git a/server/src/external/stripe/webhookMiddlewares/stripeIdempotencyMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeIdempotencyMiddleware.ts index 3c203637ba..2781c93122 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeIdempotencyMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeIdempotencyMiddleware.ts @@ -1,16 +1,72 @@ +import type { AppEnv } from "@autumn/shared"; import { tryCatch } from "@autumn/shared"; import type { Context, Next } from "hono"; import { redis } from "@/external/redis/initRedis"; -import type { StripeWebhookHonoEnv } from "./stripeWebhookContext"; +import { classifyStripeWebhookAckMode } from "./classifyStripeWebhookAckMode.js"; +import type { StripeWebhookHonoEnv } from "./stripeWebhookContext.js"; -const IDEMPOTENCY_TTL_MS = 5 * 60 * 1000; // 5 minutes +const PROCESSING_TTL_MS = 5 * 60 * 1000; +const COMPLETED_TTL_MS = 24 * 60 * 60 * 1000; +const PROCESSING = "processing"; +const COMPLETED = "completed"; + +export const buildStripeWebhookEventKey = ({ + orgId, + env, + eventId, +}: { + orgId: string; + env: AppEnv; + eventId: string; +}) => `stripe:webhook:${orgId}:${env}:${eventId}`; + +export type StripeWebhookClaimResult = + | "claimed" + | "duplicate_completed" + | "in_flight" + | "unavailable"; + +/** Atomically claims an event for processing. "unavailable" = Redis down (fail open). */ +export const claimStripeWebhookEvent = async ({ + eventKey, +}: { + eventKey: string; +}): Promise => { + if (redis.status !== "ready") return "unavailable"; + + const { data: result, error } = await tryCatch( + redis.set(eventKey, PROCESSING, "PX", PROCESSING_TTL_MS, "NX"), + ); + if (error) return "unavailable"; + if (result !== null) return "claimed"; + + const { data: existing } = await tryCatch(redis.get(eventKey)); + return existing === COMPLETED ? "duplicate_completed" : "in_flight"; +}; + +export const completeStripeWebhookEvent = async ({ + eventKey, +}: { + eventKey: string; +}) => { + await tryCatch(redis.set(eventKey, COMPLETED, "PX", COMPLETED_TTL_MS)); +}; + +export const releaseStripeWebhookEvent = async ({ + eventKey, +}: { + eventKey: string; +}) => { + await tryCatch(redis.del(eventKey)); +}; /** - * Middleware that prevents duplicate processing of Stripe webhook events. - * Uses Redis SET NX PX to atomically check and set event ID with expiry, - * ensuring only one instance processes each event even with concurrent deliveries. + * Two-state webhook idempotency: a processing lock is taken before handling, + * but the event only counts as a duplicate once marked COMPLETED (via the + * ctx.webhookIdempotency hooks). A failed run deletes the lock so Stripe's + * retry of the same event id reprocesses instead of being dropped. * - * If Redis is unavailable or errors, the middleware allows the request through (fail-open). + * If Redis is unavailable or errors, the request is allowed through (fail-open). */ export const stripeIdempotencyMiddleware = async ( c: Context, @@ -19,44 +75,47 @@ export const stripeIdempotencyMiddleware = async ( const ctx = c.get("ctx"); const { stripeEvent, org, env } = ctx; - const idempotencyKey = `stripe:webhook:${org.id}:${env}:${stripeEvent.id}`; + ctx.webhookAckMode = classifyStripeWebhookAckMode({ event: stripeEvent }); - // Fail open if Redis not ready - if (redis.status !== "ready") { - await next(); - return; - } + const eventKey = buildStripeWebhookEventKey({ + orgId: org.id, + env, + eventId: stripeEvent.id, + }); - // Atomically try to set the key with expiry - // NX = only set if key doesn't exist, PX = set expiry in milliseconds - // Returns "OK" if set, null if key already exists - const { data: result, error } = await tryCatch( - redis.set( - idempotencyKey, - Date.now().toString(), - "PX", - IDEMPOTENCY_TTL_MS, - "NX", - ), - ); + const claim = await claimStripeWebhookEvent({ eventKey }); - if (error) { - // Redis error - fail open + if (claim === "unavailable") { + // Redis down or errored - fail open ctx.logger.warn( - `[stripeIdempotencyMiddleware] Redis error, allowing through: ${error}`, + `[stripeIdempotencyMiddleware] Redis unavailable, allowing through: ${stripeEvent.id}`, ); await next(); return; } - if (result === null) { - // Key already exists - duplicate event + if ( + claim === "duplicate_completed" || + (claim === "in_flight" && ctx.webhookAckMode === "early") + ) { ctx.logger.info( `[stripeIdempotencyMiddleware] Duplicate webhook event detected, skipping: ${stripeEvent.id}`, ); return c.json({ received: true, duplicate: true }, 200); } - // Lock acquired ("OK"), proceed with processing + if (claim === "in_flight") { + // Sync event still in flight — 500 keeps Stripe retrying until completed. + ctx.logger.info( + `[stripeIdempotencyMiddleware] Sync webhook event in flight, asking Stripe to retry: ${stripeEvent.id}`, + ); + return c.json({ received: false, in_flight: true }, 500); + } + + ctx.webhookIdempotency = { + markCompleted: () => completeStripeWebhookEvent({ eventKey }), + release: () => releaseStripeWebhookEvent({ eventKey }), + }; + await next(); }; diff --git a/server/src/external/stripe/webhookMiddlewares/stripeSyncMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeSyncMiddleware.ts index 410de04ca8..720938ef06 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeSyncMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeSyncMiddleware.ts @@ -1,5 +1,6 @@ import { isSyncableEvent, processStripeSyncEvent } from "@autumn/stripe-sync"; import type { Context, Next } from "hono"; +import { orgToAccountId } from "@/external/connect/connectUtils.js"; import { isStripeSyncEnabled } from "@/internal/misc/stripeSync/stripeSyncStore.js"; import type { StripeWebhookContext, @@ -7,16 +8,15 @@ import type { } from "./stripeWebhookContext.js"; /** - * Post-handler middleware that syncs Stripe events to the sync DB. - * Fire-and-forget -- errors are caught and logged, never propagated. + * Mirrors a Stripe event into the sync DB. Fire-and-forget — errors are + * caught and logged, never propagated. Shared by the webhook route and the + * SQS replay worker. */ -export const stripeSyncMiddleware = async ( - c: Context, - next: Next, -) => { - await next(); - - const ctx = c.get("ctx") as StripeWebhookContext; +export const syncStripeEventToSyncDb = ({ + ctx, +}: { + ctx: StripeWebhookContext; +}) => { const { logger, org, stripeEvent } = ctx; if (!org || !stripeEvent) return; @@ -29,7 +29,10 @@ export const stripeSyncMiddleware = async ( if (!isSyncableEvent({ eventType: stripeEvent.type })) return; try { - const stripeAccountId = stripeEvent.account ?? undefined; + // Events from account-registered webhooks carry no `account` — resolve + // from org connect config so sync rows are never left tenant-unstamped. + const stripeAccountId = + stripeEvent.account ?? orgToAccountId({ org, env: ctx.env }); void processStripeSyncEvent({ event: stripeEvent, @@ -52,3 +55,16 @@ export const stripeSyncMiddleware = async ( logger.error(`Stripe sync middleware error: ${error}`); } }; + +/** + * Post-handler middleware that syncs Stripe events to the sync DB. + */ +export const stripeSyncMiddleware = async ( + c: Context, + next: Next, +) => { + await next(); + + const ctx = c.get("ctx") as StripeWebhookContext; + syncStripeEventToSyncDb({ ctx }); +}; diff --git a/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts index 6db1e75525..01c4b23076 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts @@ -55,30 +55,44 @@ const getAutumnCustomerId = async ({ ctx }: { ctx: StripeWebhookContext }) => { ctx.fullCustomer = fullCustomer; }; -export const stripeToAutumnCustomerMiddleware = async ( - c: Context, - next: Next, -) => { - const ctx = c.get("ctx") as StripeWebhookContext; +/** + * Resolves the event's Autumn customer onto ctx.fullCustomer and returns a + * ctx routed to that customer's Redis. Shared by the webhook route and the + * SQS replay worker. + */ +export const attachStripeEventCustomer = async ({ + ctx, +}: { + ctx: StripeWebhookContext; +}): Promise => { await getAutumnCustomerId({ ctx }); const customerId = ctx.fullCustomer?.id || ctx.fullCustomer?.internal_id || undefined; - if (customerId) { - const { ctx: routedCtx } = getCtxWithCustomerRedis({ - ctx: { - ...ctx, - customerId, - rolloutSnapshot: computeRolloutSnapshot({ - orgId: ctx.org.id, - customerId, - }), - }, + if (!customerId) return ctx; + + const { ctx: routedCtx } = getCtxWithCustomerRedis({ + ctx: { + ...ctx, customerId, - }); - c.set("ctx", routedCtx); - } + rolloutSnapshot: computeRolloutSnapshot({ + orgId: ctx.org.id, + customerId, + }), + }, + customerId, + }); + return routedCtx; +}; + +export const stripeToAutumnCustomerMiddleware = async ( + c: Context, + next: Next, +) => { + const ctx = c.get("ctx") as StripeWebhookContext; + const routedCtx = await attachStripeEventCustomer({ ctx }); + if (routedCtx !== ctx) c.set("ctx", routedCtx); await next(); }; diff --git a/server/src/external/stripe/webhookMiddlewares/stripeWebhookAckMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeWebhookAckMiddleware.ts new file mode 100644 index 0000000000..b02ec2d62a --- /dev/null +++ b/server/src/external/stripe/webhookMiddlewares/stripeWebhookAckMiddleware.ts @@ -0,0 +1,114 @@ +import { context, SpanStatusCode, trace } from "@opentelemetry/api"; +import type { Context, Next } from "hono"; +import { enqueueStripeWebhookReplay } from "../webhookReplay/enqueueStripeWebhookReplay.js"; +import { classifyStripeWebhookAckMode } from "./classifyStripeWebhookAckMode.js"; +import type { StripeWebhookHonoEnv } from "./stripeWebhookContext.js"; + +const tracer = trace.getTracer("autumn-stripe-webhook"); + +const getWaitUntil = (c: Context) => { + try { + return c.executionCtx.waitUntil.bind(c.executionCtx); + } catch { + return undefined; + } +}; + +/** + * Ack policy: "early" events are acked 200 before processing (Stripe blocks + * its own API calls on webhook delivery, so Autumn-triggered events must not + * wait). "sync" events are acked only after processing succeeds — a failure + * releases the idempotency lock and 500s so Stripe redelivers. + */ +export const stripeWebhookAckMiddleware = async ( + c: Context, + next: Next, +) => { + const ctx = c.get("ctx"); + const ackMode = + ctx.webhookAckMode ?? + classifyStripeWebhookAckMode({ event: ctx.stripeEvent }); + const idempotency = ctx.webhookIdempotency; + + if (ackMode === "sync") { + try { + await next(); + } catch (error) { + ctx.logger.error(`Stripe webhook sync processing failed: ${error}`, { + error, + }); + await idempotency?.release(); + return c.json( + { message: "Webhook processing failed, Stripe will retry" }, + 500, + ); + } + + // A throwing handler resolves next() with c.error set (app onError owns + // the response), so failure must be detected here, not via catch. + if (c.error) { + await idempotency?.release(); + return; + } + + await idempotency?.markCompleted(); + return; + } + + // Start a span that survives past the early-ack response. Parented to the + // still-active root HTTP span; ended in finally(...) when deferred work + // completes so traceEnrichMiddleware + child spans land on a live span. + const deferredSpan = tracer.startSpan("stripe.webhook.deferred"); + const ctxWithSpan = trace.setSpan(context.active(), deferredSpan); + + const runWebhook = () => + context.with(ctxWithSpan, () => + Promise.resolve() + .then(next) + .then(() => { + if (c.error) throw c.error; + return idempotency?.markCompleted(); + }) + .catch(async (error) => { + deferredSpan.recordException(error); + deferredSpan.setStatus({ code: SpanStatusCode.ERROR }); + await idempotency?.release(); + + const queued = await enqueueStripeWebhookReplay({ + ctx, + failureReason: + error instanceof Error ? error.message : String(error), + }); + ctx.logger.error( + `[stripeWebhookAck] Early-acked Stripe webhook FAILED after 200 (${ctx.stripeEvent?.type}), ${queued ? "queued for replay" : "NOT queued for replay"}`, + { + error, + data: { + eventId: ctx.stripeEvent?.id, + eventType: ctx.stripeEvent?.type, + queuedForReplay: queued, + }, + }, + ); + }) + .finally(() => deferredSpan.end()), + ); + + const waitUntil = getWaitUntil(c); + if (waitUntil) { + let webhookRun: Promise | undefined; + try { + webhookRun = runWebhook(); + waitUntil(webhookRun); + } catch (error) { + ctx.logger.error(`Stripe webhook waitUntil failed: ${error}`, { error }); + // The chain settles the claim + span itself once started; only a + // failure BEFORE start needs rescheduling so the event isn't lost. + if (!webhookRun) setImmediate(() => void runWebhook()); + } + } else { + setImmediate(() => void runWebhook()); + } + + return c.json({ received: true }, 200); +}; diff --git a/server/src/external/stripe/webhookMiddlewares/stripeWebhookContext.ts b/server/src/external/stripe/webhookMiddlewares/stripeWebhookContext.ts index 7b6abf6aa8..61f1d774e7 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeWebhookContext.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeWebhookContext.ts @@ -1,11 +1,20 @@ import type { Customer, FullCustomer } from "@autumn/shared"; import type Stripe from "stripe"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { StripeWebhookAckMode } from "./classifyStripeWebhookAckMode.js"; + +/** Never-throwing Redis hooks installed by the idempotency middleware. */ +export type StripeWebhookIdempotencyHooks = { + markCompleted: () => Promise; + release: () => Promise; +}; export interface StripeWebhookContext extends AutumnContext { stripeEvent: Stripe.Event; stripeCli: Stripe; fullCustomer?: FullCustomer; + webhookAckMode?: StripeWebhookAckMode; + webhookIdempotency?: StripeWebhookIdempotencyHooks; } export type StripeWebhookHonoEnv = { diff --git a/server/src/external/stripe/webhookMiddlewares/stripeWebhookEarlyAckMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeWebhookEarlyAckMiddleware.ts deleted file mode 100644 index fb7a68fc0e..0000000000 --- a/server/src/external/stripe/webhookMiddlewares/stripeWebhookEarlyAckMiddleware.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { SpanStatusCode, context, trace } from "@opentelemetry/api"; -import type { Context, Next } from "hono"; -import type { StripeWebhookHonoEnv } from "./stripeWebhookContext"; - -const tracer = trace.getTracer("autumn-stripe-webhook"); - -const getWaitUntil = (c: Context) => { - try { - return c.executionCtx.waitUntil.bind(c.executionCtx); - } catch { - return undefined; - } -}; - -export const stripeWebhookEarlyAckMiddleware = async ( - c: Context, - next: Next, -) => { - const ctx = c.get("ctx"); - - // Start a span that survives past the early-ack response. Parented to the - // still-active root HTTP span; ended in finally(...) when deferred work - // completes so traceEnrichMiddleware + child spans land on a live span. - const deferredSpan = tracer.startSpan("stripe.webhook.deferred"); - const ctxWithSpan = trace.setSpan(context.active(), deferredSpan); - - const runWebhook = () => - context.with(ctxWithSpan, () => - Promise.resolve() - .then(next) - .catch((error) => { - deferredSpan.recordException(error); - deferredSpan.setStatus({ code: SpanStatusCode.ERROR }); - ctx.logger.error( - `Stripe webhook background processing failed: ${error}`, - { error }, - ); - }) - .finally(() => deferredSpan.end()), - ); - - const waitUntil = getWaitUntil(c); - if (waitUntil) { - try { - waitUntil(runWebhook()); - } catch (error) { - deferredSpan.end(); - ctx.logger.error(`Stripe webhook waitUntil failed: ${error}`, { error }); - } - } else { - setImmediate(() => void runWebhook()); - } - - return c.json({ received: true }, 200); -}; diff --git a/server/src/external/stripe/webhookReplay/enqueueStripeWebhookReplay.ts b/server/src/external/stripe/webhookReplay/enqueueStripeWebhookReplay.ts new file mode 100644 index 0000000000..5595f2145f --- /dev/null +++ b/server/src/external/stripe/webhookReplay/enqueueStripeWebhookReplay.ts @@ -0,0 +1,54 @@ +import { tryCatch } from "@autumn/shared"; +import { JobName } from "@/queue/JobName.js"; +import { addTaskToQueue } from "@/queue/queueUtils.js"; +import type { StripeWebhookContext } from "../webhookMiddlewares/stripeWebhookContext.js"; + +export const getStripeWebhookReplayQueueUrl = () => + process.env.STRIPE_WEBHOOK_SQS_QUEUE_URL; + +/** + * Queues a failed early-acked webhook for replay. Never throws — the caller + * is a background failure path and enqueue is best-effort. + */ +export const enqueueStripeWebhookReplay = async ({ + ctx, + failureReason, +}: { + ctx: StripeWebhookContext; + failureReason: string; +}): Promise => { + const { org, env, stripeEvent, logger } = ctx; + + const queueUrl = getStripeWebhookReplayQueueUrl(); + if (!org || !stripeEvent) return false; + if (!queueUrl) { + logger.warn( + `[stripeWebhookReplay] STRIPE_WEBHOOK_SQS_QUEUE_URL not set, cannot queue failed event ${stripeEvent.id} for replay`, + ); + return false; + } + + const { error } = await tryCatch( + addTaskToQueue({ + jobName: JobName.StripeWebhookReplay, + queueUrl, + payload: { + orgId: org.id, + env, + stripeEvent, + failedAt: Date.now(), + failureReason, + }, + }), + ); + + if (error) { + logger.error( + `[stripeWebhookReplay] Failed to queue event ${stripeEvent.id} for replay: ${error}`, + { error }, + ); + return false; + } + + return true; +}; diff --git a/server/src/external/stripe/webhookReplay/runStripeWebhookReplay.ts b/server/src/external/stripe/webhookReplay/runStripeWebhookReplay.ts new file mode 100644 index 0000000000..bf91368935 --- /dev/null +++ b/server/src/external/stripe/webhookReplay/runStripeWebhookReplay.ts @@ -0,0 +1,99 @@ +import { AuthType, tryCatch } from "@autumn/shared"; +import type Stripe from "stripe"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; +import { runStripeWebhookHandlers } from "../runStripeWebhookHandlers.js"; +import { + buildStripeWebhookEventKey, + claimStripeWebhookEvent, + completeStripeWebhookEvent, + releaseStripeWebhookEvent, +} from "../webhookMiddlewares/stripeIdempotencyMiddleware.js"; +import { syncStripeEventToSyncDb } from "../webhookMiddlewares/stripeSyncMiddleware.js"; +import { attachStripeEventCustomer } from "../webhookMiddlewares/stripeToAutumnCustomerMiddleware.js"; +import type { StripeWebhookContext } from "../webhookMiddlewares/stripeWebhookContext.js"; + +export type StripeWebhookReplayPayload = { + orgId: string; + env: AutumnContext["env"]; + stripeEvent: Stripe.Event; + failedAt: number; + failureReason: string; +}; + +/** Thrown when another instance holds the event lock; retryable via SQS. */ +export class StripeWebhookReplayInFlightError extends Error { + constructor(eventId: string) { + super(`Stripe webhook replay in flight for event ${eventId}`); + this.name = "StripeWebhookReplayInFlightError"; + } +} + +/** + * Worker entry: replays a failed early-acked Stripe webhook through the same + * pipeline the route runs (customer resolution -> handlers -> cache refresh + * -> sync mirror), guarded by the same Redis idempotency claim. + */ +export const runStripeWebhookReplay = async ({ + ctx, + payload, +}: { + ctx: AutumnContext; + payload: StripeWebhookReplayPayload; +}) => { + const { stripeEvent } = payload; + const { logger } = ctx; + + const webhookCtx: StripeWebhookContext = { + ...ctx, + authType: AuthType.Stripe, + stripeEvent, + stripeCli: createStripeCli({ org: ctx.org, env: ctx.env }), + }; + + const routedCtx = await attachStripeEventCustomer({ ctx: webhookCtx }); + + const eventKey = buildStripeWebhookEventKey({ + orgId: ctx.org.id, + env: ctx.env, + eventId: stripeEvent.id, + }); + const claim = await claimStripeWebhookEvent({ eventKey }); + + if (claim === "duplicate_completed") { + logger.info( + `[stripeWebhookReplay] Event ${stripeEvent.id} already completed elsewhere, skipping replay`, + ); + return; + } + + if (claim === "in_flight") { + throw new StripeWebhookReplayInFlightError(stripeEvent.id); + } + + logger.info( + `[stripeWebhookReplay] Replaying ${stripeEvent.type} (${stripeEvent.id}), originally failed at ${new Date(payload.failedAt).toISOString()}: ${payload.failureReason}`, + ); + + try { + await runStripeWebhookHandlers({ ctx: routedCtx }); + } catch (error) { + if (claim === "claimed") await releaseStripeWebhookEvent({ eventKey }); + throw error; + } + + if (claim === "claimed") await completeStripeWebhookEvent({ eventKey }); + + // Post-processing mirrors the route's refresh + sync middlewares (best-effort). + if (routedCtx.fullCustomer?.id) { + await tryCatch( + deleteCachedFullCustomer({ + customerId: routedCtx.fullCustomer.id, + ctx: routedCtx, + source: `stripeWebhookReplay: ${stripeEvent.type}`, + }), + ); + } + syncStripeEventToSyncDb({ ctx: routedCtx }); +}; diff --git a/server/src/honoMiddlewares/baseMiddleware.ts b/server/src/honoMiddlewares/baseMiddleware.ts index e40288a165..dc91a0b4f1 100644 --- a/server/src/honoMiddlewares/baseMiddleware.ts +++ b/server/src/honoMiddlewares/baseMiddleware.ts @@ -124,6 +124,7 @@ export const baseMiddleware = async (c: Context, next: Next) => { userId: undefined, customerId, entityId, + requestBody: body, authType: AuthType.Unknown, env: AppEnv.Sandbox, // maybe use app_env headers scopes: [], @@ -141,6 +142,14 @@ export const baseMiddleware = async (c: Context, next: Next) => { eventId: c.req.header("x-event-id"), skipCacheDeletion: c.req.header("x-skip-cache-deletion") === "true", skipWebhooks: c.req.header("x-skip-webhooks") === "true", + syncCoalesce: + process.env.NODE_ENV !== "production" + ? c.req.header("x-sync-coalesce") === "true" + ? true + : c.req.header("x-sync-coalesce") === "false" + ? false + : undefined + : undefined, keepInternalFields: c.req.header("x-strip-internal") === "false", useReplica: c.req.header("x-use-replica") === "true", mockVercelApi: c.req.header("x-mock-vercel-api") === "true", diff --git a/server/src/honoUtils/HonoEnv.ts b/server/src/honoUtils/HonoEnv.ts index 628cccf671..176b731f4c 100644 --- a/server/src/honoUtils/HonoEnv.ts +++ b/server/src/honoUtils/HonoEnv.ts @@ -31,6 +31,7 @@ export type RequestContext = { oauthResource?: string; customerId?: string; entityId?: string; + requestBody?: unknown; // Objects db: DrizzleCli; @@ -93,6 +94,9 @@ export type RequestContext = { testOptions?: { skipCacheDeletion?: boolean; skipWebhooks?: boolean; + /** Per-request sync-coalesce gate override (non-prod only); undefined + * falls through to the edge config. */ + syncCoalesce?: boolean; eventId?: string; keepInternalFields?: boolean; useReplica?: boolean; diff --git a/server/src/init.ts b/server/src/init.ts index 254807594e..1f5202a5f5 100644 --- a/server/src/init.ts +++ b/server/src/init.ts @@ -26,8 +26,13 @@ import "./internal/misc/customerBlocks/customerBlockStore.js"; import "./internal/misc/edgeConfig/orgLimitsStore.js"; import "./internal/misc/stripeSync/stripeSyncStore.js"; import "./internal/misc/redisV2Cache/redisV2CacheStore.js"; +import "./internal/misc/mainRedisCache/mainRedisCacheStore.js"; import "./internal/misc/cacheV2Ramp/cacheV2RampStore.js"; import "./internal/misc/jobQueues/jobQueueStore.js"; +import "./internal/misc/batchReset/batchResetConfigStore.js"; +import "./internal/misc/resetJob/resetJobStore.js"; +import "./internal/misc/resetJobV2/resetJobV2Store.js"; + // Side-effect: configures trigger.dev SDK to use TRIGGER_SERVER_SECRET_KEY. import "./trigger/configureTrigger.js"; import { closeStripeSyncEngine } from "@autumn/stripe-sync"; diff --git a/server/src/internal/admin/adminRouter.ts b/server/src/internal/admin/adminRouter.ts index e46ab90fa4..663fc3e8ad 100644 --- a/server/src/internal/admin/adminRouter.ts +++ b/server/src/internal/admin/adminRouter.ts @@ -13,11 +13,13 @@ import { handleUpdateAdminOrgRedisPublicUrl, handleUpsertAdminOrgRedisConfig, } from "./handleAdminOrgRedisConfig"; +import { handleGetAdminBatchResetConfig } from "./handleGetAdminBatchResetConfig"; import { handleGetAdminCustomerBlockConfig } from "./handleGetAdminCustomerBlockConfig"; import { handleGetAdminEdgeConfigSources } from "./handleGetAdminEdgeConfigSources"; import { handleGetAdminFeatureFlagsConfig } from "./handleGetAdminFeatureFlagsConfig"; import { handleGetAdminFullSubjectGateConfig } from "./handleGetAdminFullSubjectGateConfig"; import { handleGetAdminJobQueueConfig } from "./handleGetAdminJobQueueConfig"; +import { handleGetAdminMainRedisCacheConfig } from "./handleGetAdminMainRedisCacheConfig"; import { handleGetAdminMiscellaneousEdgeConfig } from "./handleGetAdminMiscellaneousEdgeConfig"; import { handleGetAdminOrgLimitsConfig } from "./handleGetAdminOrgLimitsConfig"; import { handleGetAdminOrgRequestBlock } from "./handleGetAdminOrgRequestBlock"; @@ -25,6 +27,8 @@ import { handleGetAdminRateLimitOverridesConfig } from "./handleGetAdminRateLimi import { handleGetAdminRateLimitRedisAllowlistConfig } from "./handleGetAdminRateLimitRedisAllowlistConfig"; import { handleGetAdminRedisV2CacheConfig } from "./handleGetAdminRedisV2CacheConfig"; import { handleGetAdminRequestBlockConfig } from "./handleGetAdminRequestBlockConfig"; +import { handleGetAdminResetJobConfig } from "./handleGetAdminResetJobConfig"; +import { handleGetAdminResetJobV2Config } from "./handleGetAdminResetJobV2Config"; import { handleGetAdminStripeSyncConfig } from "./handleGetAdminStripeSyncConfig"; import { handleGetDefaultStripeAccount } from "./handleGetDefaultStripeAccount"; @@ -40,10 +44,12 @@ import { handleGetSlackAdminInstall, handleUpdateSlackAdminTarget, } from "./handleSlackAdminChat"; +import { handleUpsertAdminBatchResetConfig } from "./handleUpsertAdminBatchResetConfig"; import { handleUpsertAdminCustomerBlockConfig } from "./handleUpsertAdminCustomerBlockConfig"; import { handleUpsertAdminFeatureFlagsConfig } from "./handleUpsertAdminFeatureFlagsConfig"; import { handleUpsertAdminFullSubjectGateConfig } from "./handleUpsertAdminFullSubjectGateConfig"; import { handleUpsertAdminJobQueueConfig } from "./handleUpsertAdminJobQueueConfig"; +import { handleUpsertAdminMainRedisCacheConfig } from "./handleUpsertAdminMainRedisCacheConfig"; import { handleUpsertAdminMiscellaneousEdgeConfig } from "./handleUpsertAdminMiscellaneousEdgeConfig"; import { handleUpsertAdminOrgLimitsConfig } from "./handleUpsertAdminOrgLimitsConfig"; import { handleUpsertAdminOrgRequestBlock } from "./handleUpsertAdminOrgRequestBlock"; @@ -51,6 +57,8 @@ import { handleUpsertAdminRateLimitOverridesConfig } from "./handleUpsertAdminRa import { handleUpsertAdminRateLimitRedisAllowlistConfig } from "./handleUpsertAdminRateLimitRedisAllowlistConfig"; import { handleUpsertAdminRedisV2CacheConfig } from "./handleUpsertAdminRedisV2CacheConfig"; import { handleUpsertAdminRequestBlockConfig } from "./handleUpsertAdminRequestBlockConfig"; +import { handleUpsertAdminResetJobConfig } from "./handleUpsertAdminResetJobConfig"; +import { handleUpsertAdminResetJobV2Config } from "./handleUpsertAdminResetJobV2Config"; import { handleUpsertAdminStripeSyncConfig } from "./handleUpsertAdminStripeSyncConfig"; import { handleUpsertSlackMcpOAuthClient } from "./handleUpsertSlackMcpOAuthClient"; import { handleDeleteRollout } from "./rollouts/handleDeleteRollout"; @@ -149,6 +157,18 @@ honoAdminRouter.put( ); honoAdminRouter.get("/job-queue-config", ...handleGetAdminJobQueueConfig); honoAdminRouter.put("/job-queue-config", ...handleUpsertAdminJobQueueConfig); +honoAdminRouter.get("/batch-reset-config", ...handleGetAdminBatchResetConfig); +honoAdminRouter.put( + "/batch-reset-config", + ...handleUpsertAdminBatchResetConfig, +); +honoAdminRouter.get("/reset-job-config", ...handleGetAdminResetJobConfig); +honoAdminRouter.put("/reset-job-config", ...handleUpsertAdminResetJobConfig); +honoAdminRouter.get("/reset-job-v2-config", ...handleGetAdminResetJobV2Config); +honoAdminRouter.put( + "/reset-job-v2-config", + ...handleUpsertAdminResetJobV2Config, +); honoAdminRouter.get("/stripe-sync-config", ...handleGetAdminStripeSyncConfig); honoAdminRouter.put( "/stripe-sync-config", @@ -162,6 +182,14 @@ honoAdminRouter.put( "/redis-v2-cache-config", ...handleUpsertAdminRedisV2CacheConfig, ); +honoAdminRouter.get( + "/main-redis-cache-config", + ...handleGetAdminMainRedisCacheConfig, +); +honoAdminRouter.put( + "/main-redis-cache-config", + ...handleUpsertAdminMainRedisCacheConfig, +); honoAdminRouter.get("/cache-v2-ramp", ...handleGetAdminCacheV2Ramp); honoAdminRouter.patch("/cache-v2-ramp", ...handleUpsertAdminCacheV2Ramp); honoAdminRouter.patch( @@ -171,7 +199,10 @@ honoAdminRouter.patch( honoAdminRouter.delete("/cache-v2-ramp", ...handleDeleteAdminCacheV2Ramp); honoAdminRouter.get("/org-member", ...handleGetOrgMember); honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount); -honoAdminRouter.get("/default-stripe-account", ...handleGetDefaultStripeAccount); +honoAdminRouter.get( + "/default-stripe-account", + ...handleGetDefaultStripeAccount, +); honoAdminRouter.get("/oauth-clients", ...handleListOAuthClients); honoAdminRouter.post( "/oauth-clients/slack-mcp", diff --git a/server/src/internal/admin/handleGetAdminBatchResetConfig.ts b/server/src/internal/admin/handleGetAdminBatchResetConfig.ts new file mode 100644 index 0000000000..9bf18e0d74 --- /dev/null +++ b/server/src/internal/admin/handleGetAdminBatchResetConfig.ts @@ -0,0 +1,22 @@ +import { Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { + getBatchResetConfigFromSource, + getBatchResetConfigStatus, +} from "@/internal/misc/batchReset/batchResetConfigStore.js"; + +export const handleGetAdminBatchResetConfig = createRoute({ + scopes: [Scopes.Superuser], + handler: async (c) => { + const status = getBatchResetConfigStatus(); + const config = await getBatchResetConfigFromSource(); + + return c.json({ + ...config, + configHealthy: status.healthy, + configConfigured: status.configured, + lastSuccessAt: status.lastSuccessAt ?? null, + error: status.error ?? null, + }); + }, +}); diff --git a/server/src/internal/admin/handleGetAdminMainRedisCacheConfig.ts b/server/src/internal/admin/handleGetAdminMainRedisCacheConfig.ts new file mode 100644 index 0000000000..fda85ca227 --- /dev/null +++ b/server/src/internal/admin/handleGetAdminMainRedisCacheConfig.ts @@ -0,0 +1,25 @@ +import { Scopes } from "@autumn/shared"; +import { getFallbackRedis } from "@/external/redis/initRedis.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { + getActiveMainRedisInstance, + getMainRedisCacheStatus, +} from "@/internal/misc/mainRedisCache/mainRedisCacheStore.js"; + +export const handleGetAdminMainRedisCacheConfig = createRoute({ + scopes: [Scopes.Superuser], + handler: async (c) => { + const status = getMainRedisCacheStatus(); + const fallback = getFallbackRedis(); + + return c.json({ + activeInstance: getActiveMainRedisInstance(), + fallbackConfigured: Boolean(fallback), + fallbackStatus: fallback?.status ?? "not_configured", + configHealthy: status.healthy, + configConfigured: status.configured, + lastSuccessAt: status.lastSuccessAt ?? null, + error: status.error ?? null, + }); + }, +}); diff --git a/server/src/internal/admin/handleGetAdminResetJobConfig.ts b/server/src/internal/admin/handleGetAdminResetJobConfig.ts new file mode 100644 index 0000000000..836d3e3ec1 --- /dev/null +++ b/server/src/internal/admin/handleGetAdminResetJobConfig.ts @@ -0,0 +1,22 @@ +import { Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { + getResetJobConfigFromSource, + getResetJobConfigStatus, +} from "@/internal/misc/resetJob/resetJobStore.js"; + +export const handleGetAdminResetJobConfig = createRoute({ + scopes: [Scopes.Superuser], + handler: async (c) => { + const status = getResetJobConfigStatus(); + const config = await getResetJobConfigFromSource(); + + return c.json({ + ...config, + configHealthy: status.healthy, + configConfigured: status.configured, + lastSuccessAt: status.lastSuccessAt ?? null, + error: status.error ?? null, + }); + }, +}); diff --git a/server/src/internal/admin/handleGetAdminResetJobV2Config.ts b/server/src/internal/admin/handleGetAdminResetJobV2Config.ts new file mode 100644 index 0000000000..338c7fdcdf --- /dev/null +++ b/server/src/internal/admin/handleGetAdminResetJobV2Config.ts @@ -0,0 +1,24 @@ +import { Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { RESET_JOB_V2_CONFIG_LIMITS } from "@/internal/misc/resetJobV2/resetJobV2Schemas.js"; +import { + getResetJobV2ConfigFromSource, + getResetJobV2ConfigStatus, +} from "@/internal/misc/resetJobV2/resetJobV2Store.js"; + +export const handleGetAdminResetJobV2Config = createRoute({ + scopes: [Scopes.Superuser], + handler: async (c) => { + const status = getResetJobV2ConfigStatus(); + const config = await getResetJobV2ConfigFromSource(); + + return c.json({ + ...config, + limits: RESET_JOB_V2_CONFIG_LIMITS, + configHealthy: status.healthy, + configConfigured: status.configured, + lastSuccessAt: status.lastSuccessAt ?? null, + error: status.error ?? null, + }); + }, +}); diff --git a/server/src/internal/admin/handleUpsertAdminBatchResetConfig.ts b/server/src/internal/admin/handleUpsertAdminBatchResetConfig.ts new file mode 100644 index 0000000000..e6c43b1302 --- /dev/null +++ b/server/src/internal/admin/handleUpsertAdminBatchResetConfig.ts @@ -0,0 +1,16 @@ +import { Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { BatchResetConfigSchema } from "@/internal/misc/batchReset/batchResetConfigSchemas.js"; +import { updateFullBatchResetConfig } from "@/internal/misc/batchReset/batchResetConfigStore.js"; + +export const handleUpsertAdminBatchResetConfig = createRoute({ + scopes: [Scopes.Superuser], + body: BatchResetConfigSchema, + handler: async (c) => { + const body = c.req.valid("json"); + + await updateFullBatchResetConfig({ config: body }); + + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/admin/handleUpsertAdminMainRedisCacheConfig.ts b/server/src/internal/admin/handleUpsertAdminMainRedisCacheConfig.ts new file mode 100644 index 0000000000..d2d61d00df --- /dev/null +++ b/server/src/internal/admin/handleUpsertAdminMainRedisCacheConfig.ts @@ -0,0 +1,37 @@ +import { ErrCode, RecaseError, Scopes } from "@autumn/shared"; +import { getFallbackRedis } from "@/external/redis/initRedis.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { MainRedisCacheConfigSchema } from "@/internal/misc/mainRedisCache/mainRedisCacheSchemas.js"; +import { updateActiveMainRedisInstance } from "@/internal/misc/mainRedisCache/mainRedisCacheStore.js"; + +export const handleUpsertAdminMainRedisCacheConfig = createRoute({ + scopes: [Scopes.Superuser], + body: MainRedisCacheConfigSchema, + handler: async (c) => { + const { activeInstance } = c.req.valid("json"); + + if (activeInstance === "fallback") { + const fallback = getFallbackRedis(); + if (!fallback || fallback.status !== "ready") { + throw new RecaseError({ + message: + "CACHE_BACKUP_URL is not configured or its Redis client is not ready", + code: ErrCode.InvalidRequest, + statusCode: 503, + }); + } + + const pong = await fallback.ping().catch(() => null); + if (pong !== "PONG") { + throw new RecaseError({ + message: "Fallback Redis did not respond to its readiness check", + code: ErrCode.InvalidRequest, + statusCode: 503, + }); + } + } + + await updateActiveMainRedisInstance({ activeInstance }); + return c.json({ success: true, activeInstance }); + }, +}); diff --git a/server/src/internal/admin/handleUpsertAdminResetJobConfig.ts b/server/src/internal/admin/handleUpsertAdminResetJobConfig.ts new file mode 100644 index 0000000000..910ef42653 --- /dev/null +++ b/server/src/internal/admin/handleUpsertAdminResetJobConfig.ts @@ -0,0 +1,16 @@ +import { Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { ResetJobConfigSchema } from "@/internal/misc/resetJob/resetJobSchemas.js"; +import { updateResetJobConfig } from "@/internal/misc/resetJob/resetJobStore.js"; + +export const handleUpsertAdminResetJobConfig = createRoute({ + scopes: [Scopes.Superuser], + body: ResetJobConfigSchema, + handler: async (c) => { + const config = c.req.valid("json"); + + await updateResetJobConfig({ config }); + + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/admin/handleUpsertAdminResetJobV2Config.ts b/server/src/internal/admin/handleUpsertAdminResetJobV2Config.ts new file mode 100644 index 0000000000..6100782357 --- /dev/null +++ b/server/src/internal/admin/handleUpsertAdminResetJobV2Config.ts @@ -0,0 +1,16 @@ +import { Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { ResetJobV2ConfigSchema } from "@/internal/misc/resetJobV2/resetJobV2Schemas.js"; +import { updateResetJobV2Config } from "@/internal/misc/resetJobV2/resetJobV2Store.js"; + +export const handleUpsertAdminResetJobV2Config = createRoute({ + scopes: [Scopes.Superuser], + body: ResetJobV2ConfigSchema, + handler: async (c) => { + const config = c.req.valid("json"); + + await updateResetJobV2Config({ config }); + + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/auth/handleListAuthOrganizations.ts b/server/src/internal/auth/handleListAuthOrganizations.ts index 9a8d5d2276..c7c65c90eb 100644 --- a/server/src/internal/auth/handleListAuthOrganizations.ts +++ b/server/src/internal/auth/handleListAuthOrganizations.ts @@ -20,7 +20,7 @@ export const handleListAuthOrganizations = async (c: Context) => { } const orgs = await db - .select({ + .selectDistinct({ id: organizations.id, name: organizations.name, slug: organizations.slug, diff --git a/server/src/internal/balances/batchReset/batchResetCustomerEntitlementsV2.ts b/server/src/internal/balances/batchReset/batchResetCustomerEntitlementsV2.ts new file mode 100644 index 0000000000..d6d2d40511 --- /dev/null +++ b/server/src/internal/balances/batchReset/batchResetCustomerEntitlementsV2.ts @@ -0,0 +1,85 @@ +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import type { Logger } from "@/external/logtail/logtailUtils.js"; +import { classifyBatchResetContext } from "./compute/classifyBatchResetContext.js"; +import { computeResetMutations } from "./compute/computeResets/computeResetMutations.js"; +import { computeVerdictMutations } from "./compute/computeVerdictMutations.js"; +import { executeResetMutations } from "./execute/executeResetMutations.js"; +import { executeVerdictMutations } from "./execute/executeVerdictMutations.js"; +import { invalidateResetCaches } from "./execute/invalidateResetCaches.js"; +import { logBatchCustomerEntitlementsV2 } from "./logs/logBatchCustomerEntitlementsV2.js"; +import { setupBatchResetContext } from "./setup/setupBatchResetContext.js"; +import type { BatchResetCustomerEntitlementsV2Payload } from "./types.js"; + +/** + * SQS handler: hydrates scanned customer entitlement IDs, builds one context + * per org+env, classifies every candidate, then computes and persists batched + * verdict and reset mutations. + */ +export const batchResetCustomerEntitlementsV2 = async ({ + db, + logger, + payload, +}: { + db: DrizzleCli; + logger: Logger; + payload: BatchResetCustomerEntitlementsV2Payload; +}) => { + const batchResetContext = await setupBatchResetContext({ + db, + logger, + payload, + }); + + const classifiedBatchResetContext = classifyBatchResetContext({ + batchResetContext, + }); + + const verdictMutations = computeVerdictMutations({ + verdicts: classifiedBatchResetContext.verdicts, + }); + + const resetMutations = await computeResetMutations({ + resetGroups: classifiedBatchResetContext.resetGroups, + }); + + logBatchCustomerEntitlementsV2({ + logger, + payload, + batchResetContext, + classifiedBatchResetContext, + verdictMutations, + resetMutations, + }); + + await executeVerdictMutations({ db, verdictMutations }); + const { appliedCustomerEntitlementIds, staleSkippedCount } = + await executeResetMutations({ db, resetMutations }); + + if (staleSkippedCount > 0) { + // A concurrent reset (usually a lazy reset racing this worker) won — + // the guarded UPDATE skipped those rows instead of double-applying. + logger.info("[batchReset] stale mutations skipped", { + jobName: "reset-cus-ents-v2", + data: { staleSkippedCount }, + }); + } + + const appliedResetMutations = resetMutations.filter( + ({ customerEntitlementId }) => + appliedCustomerEntitlementIds.has(customerEntitlementId), + ); + + // Postgres is authoritative now — drop the stale Redis state so the next + // read rehydrates instead of serving pre-reset balances. + await invalidateResetCaches({ + resetGroups: classifiedBatchResetContext.resetGroups, + resetMutations: appliedResetMutations, + }); + + return { + ...classifiedBatchResetContext, + verdictMutations, + resetMutations, + appliedResetMutations, + }; +}; diff --git a/server/src/internal/balances/batchReset/compute/classifyBatchResetContext.ts b/server/src/internal/balances/batchReset/compute/classifyBatchResetContext.ts new file mode 100644 index 0000000000..32610c4b96 --- /dev/null +++ b/server/src/internal/balances/batchReset/compute/classifyBatchResetContext.ts @@ -0,0 +1,52 @@ +import type { ResetContextCustomerEntitlement } from "@/internal/customers/cusProducts/cusEnts/repos/getResetContextByIds.js"; +import type { + BatchResetContext, + BatchResetGroup, + ResetVerdict, +} from "../types.js"; +import { classifyResetCandidate } from "./classifyResetCandidate.js"; + +export type ClassifiedBatchResetContext = { + /** Groups containing only customer entitlements that should be reset. */ + resetGroups: BatchResetGroup[]; + /** All non-reset outcomes, flattened for one later bulk write. */ + verdicts: ResetVerdict[]; +}; + +/** + * Pure classification stage for one hydrated SQS batch. + * + * Org contexts stay attached to resettable entitlements because reset + * computation depends on org configuration. Verdicts are tenant-independent + * row updates, so they are flattened for the later persistence stage. + */ +export const classifyBatchResetContext = ({ + batchResetContext, +}: { + batchResetContext: BatchResetContext; +}): ClassifiedBatchResetContext => { + const resetGroups: BatchResetGroup[] = []; + const verdicts: ResetVerdict[] = []; + + for (const group of batchResetContext.groups) { + const resettable: ResetContextCustomerEntitlement[] = []; + + for (const customerEntitlement of group.customerEntitlements) { + const verdict = classifyResetCandidate({ customerEntitlement }); + if (verdict) { + verdicts.push(verdict); + } else { + resettable.push(customerEntitlement); + } + } + + if (resettable.length > 0) { + resetGroups.push({ + ctx: group.ctx, + customerEntitlements: resettable, + }); + } + } + + return { resetGroups, verdicts }; +}; diff --git a/server/src/internal/balances/batchReset/compute/classifyNoAction.ts b/server/src/internal/balances/batchReset/compute/classifyNoAction.ts new file mode 100644 index 0000000000..05b6257b2c --- /dev/null +++ b/server/src/internal/balances/batchReset/compute/classifyNoAction.ts @@ -0,0 +1,93 @@ +import { + CusProductStatus, + isPooledBalanceSourceCustomerEntitlement, + isSyntheticPooledBalanceCustomerEntitlement, + PooledBalanceResetMode, +} from "@autumn/shared"; +import type { ResetContextCustomerEntitlement } from "@/internal/customers/cusProducts/cusEnts/repos/getResetContextByIds.js"; +import type { ResetVerdict } from "../types.js"; + +/** Classifies candidates that should not be mutated by this worker. */ +export const classifyNoAction = ({ + customerEntitlement, +}: { + customerEntitlement: ResetContextCustomerEntitlement; +}): ResetVerdict | null => { + // Not overdue (or no reset schedule at all): the scanner re-enqueues + // across sweeps and SQS is at-least-once, so redelivered rows that were + // already reset MUST be a no-op — otherwise a redelivery would re-top the + // balance and push next_reset_at another cycle forward. + if ( + customerEntitlement.next_reset_at == null || + customerEntitlement.next_reset_at >= Date.now() + ) { + return { + kind: "no_action", + customerEntitlementId: customerEntitlement.id, + reason: "not_due", + }; + } + + if ( + isPooledBalanceSourceCustomerEntitlement({ customerEntitlement }) + ) { + return { + kind: "no_action", + customerEntitlementId: customerEntitlement.id, + reason: "pooled_balance_source", + }; + } + + if ( + isSyntheticPooledBalanceCustomerEntitlement({ customerEntitlement }) + ) { + if (!customerEntitlement.pooled_balance) { + return { + kind: "no_action", + customerEntitlementId: customerEntitlement.id, + reason: "pooled_balance_missing", + }; + } + + if ( + customerEntitlement.pooled_balance.reset_mode === + PooledBalanceResetMode.Subscription + ) { + return { + kind: "resets_via_invoice", + customerEntitlementId: customerEntitlement.id, + }; + } + } + + const customerProduct = customerEntitlement.customer_product; + if (!customerProduct) return null; + + if ( + customerProduct.status === CusProductStatus.PastDue && + !customerProduct.product.config?.ignore_past_due + ) { + return { + kind: "no_action", + customerEntitlementId: customerEntitlement.id, + reason: "product_past_due", + }; + } + + // Parity with the lazy path (and the V1 cron): only Active and gated + // PastDue products reset. Scheduled/Trialing/Paused/etc. must not have + // their balances topped up or next_reset_at advanced. (Expired products + // are handled earlier by classifyShouldExpire.) + if ( + customerProduct.status !== CusProductStatus.Active && + customerProduct.status !== CusProductStatus.PastDue + ) { + return { + kind: "no_action", + customerEntitlementId: customerEntitlement.id, + reason: "product_not_active", + }; + } + + return null; +}; diff --git a/server/src/internal/balances/batchReset/compute/classifyResetCandidate.ts b/server/src/internal/balances/batchReset/compute/classifyResetCandidate.ts new file mode 100644 index 0000000000..c6a4f570a7 --- /dev/null +++ b/server/src/internal/balances/batchReset/compute/classifyResetCandidate.ts @@ -0,0 +1,58 @@ +import { + cusEntToCusPrice, + isCustomerEntitlementPrepaidWithSeparateResetInterval, + isLifetimeEntitlement, + isUnlimitedEntitlement, +} from "@autumn/shared"; +import type { ResetContextCustomerEntitlement } from "@/internal/customers/cusProducts/cusEnts/repos/getResetContextByIds.js"; +import type { ResetVerdict } from "../types.js"; +import { classifyNoAction } from "./classifyNoAction.js"; +import { classifyShouldExpire } from "./classifyShouldExpire.js"; + +export const classifyResetCandidate = ({ + customerEntitlement, +}: { + customerEntitlement: ResetContextCustomerEntitlement; +}): ResetVerdict | null => { + const customerEntitlementId = customerEntitlement.id; + const entitlement = customerEntitlement.entitlement; + + const shouldExpire = classifyShouldExpire({ customerEntitlement }); + if (shouldExpire) return shouldExpire; + + const noAction = classifyNoAction({ customerEntitlement }); + if (noAction) return noAction; + + if (isUnlimitedEntitlement({ entitlement })) { + return { kind: "clear_next_reset", customerEntitlementId, unlimited: true }; + } + + if (isLifetimeEntitlement({ entitlement })) { + return { + kind: "clear_next_reset", + customerEntitlementId, + unlimited: false, + }; + } + + const customerProduct = customerEntitlement.customer_product; + if (!customerProduct) return null; + + // Price-backed entitlements on a live subscription reset from + // invoice.created, not here — except split prepaid reset intervals. + // Without a subscription there is no invoice, so the reset stays ours. + const customerPrice = cusEntToCusPrice({ cusEnt: customerEntitlement }); + const hasSubscription = (customerProduct.subscription_ids?.length ?? 0) > 0; + if ( + customerPrice && + hasSubscription && + !isCustomerEntitlementPrepaidWithSeparateResetInterval({ + customerEntitlement, + customerPrice, + }) + ) { + return { kind: "resets_via_invoice", customerEntitlementId }; + } + + return null; +}; diff --git a/server/src/internal/balances/batchReset/compute/classifyShouldExpire.ts b/server/src/internal/balances/batchReset/compute/classifyShouldExpire.ts new file mode 100644 index 0000000000..8b605cede0 --- /dev/null +++ b/server/src/internal/balances/batchReset/compute/classifyShouldExpire.ts @@ -0,0 +1,23 @@ +import { CusProductStatus } from "@autumn/shared"; +import type { ResetContextCustomerEntitlement } from "@/internal/customers/cusProducts/cusEnts/repos/getResetContextByIds.js"; +import type { ResetVerdict } from "../types.js"; + +/** + * Marks a customer entitlement for expiry denormalization when its owning + * customer product is expired but the denormalized flag is not yet true. + */ +export const classifyShouldExpire = ({ + customerEntitlement, +}: { + customerEntitlement: ResetContextCustomerEntitlement; +}): ResetVerdict | null => { + if (customerEntitlement.expired === true) return null; + + const customerProduct = customerEntitlement.customer_product; + if (customerProduct?.status !== CusProductStatus.Expired) return null; + + return { + kind: "should_expire", + customerEntitlementId: customerEntitlement.id, + }; +}; diff --git a/server/src/internal/balances/batchReset/compute/computeResets/computeResetMutation.ts b/server/src/internal/balances/batchReset/compute/computeResets/computeResetMutation.ts new file mode 100644 index 0000000000..85b864aefa --- /dev/null +++ b/server/src/internal/balances/batchReset/compute/computeResets/computeResetMutation.ts @@ -0,0 +1,59 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { processReset } from "@/internal/customers/actions/resetCustomerEntitlements/processReset.js"; +import { performMaximumClearing } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js"; +import type { ResetContextCustomerEntitlement } from "@/internal/customers/cusProducts/cusEnts/repos/getResetContextByIds.js"; +import type { ResetMutation } from "../../types.js"; + +export const computeResetMutation = async ({ + ctx, + customerEntitlement, +}: { + ctx: AutumnContext; + customerEntitlement: ResetContextCustomerEntitlement; +}): Promise => { + // Guaranteed non-null by the not_due classification; re-checked for the + // optimistic guard the execute UPDATE compares against. + const expectedNextResetAt = customerEntitlement.next_reset_at; + if (expectedNextResetAt == null) return null; + + const result = await processReset({ + ctx, + cusEnt: customerEntitlement, + }); + + if (!result) return null; + + const newRollovers = result.rolloverInsert?.rows ?? []; + if (newRollovers.length === 0) { + return { + customerEntitlementId: customerEntitlement.id, + expectedNextResetAt, + updates: result.updates, + rolloverInserts: [], + rolloverUpdates: [], + rolloverDeleteIds: [], + }; + } + + const { toDelete, toUpdate } = performMaximumClearing({ + rows: [...customerEntitlement.rollovers, ...newRollovers], + cusEnt: customerEntitlement, + }); + + const newRolloverIds = new Set(newRollovers.map(({ id }) => id)); + const deletedRolloverIds = new Set(toDelete); + const updatedRolloversById = new Map( + toUpdate.map((rollover) => [rollover.id, rollover]), + ); + + return { + customerEntitlementId: customerEntitlement.id, + expectedNextResetAt, + updates: result.updates, + rolloverInserts: newRollovers + .filter(({ id }) => !deletedRolloverIds.has(id)) + .map((rollover) => updatedRolloversById.get(rollover.id) ?? rollover), + rolloverUpdates: toUpdate.filter(({ id }) => !newRolloverIds.has(id)), + rolloverDeleteIds: toDelete.filter((id) => !newRolloverIds.has(id)), + }; +}; diff --git a/server/src/internal/balances/batchReset/compute/computeResets/computeResetMutations.ts b/server/src/internal/balances/batchReset/compute/computeResets/computeResetMutations.ts new file mode 100644 index 0000000000..2b08821c63 --- /dev/null +++ b/server/src/internal/balances/batchReset/compute/computeResets/computeResetMutations.ts @@ -0,0 +1,29 @@ +import pLimit from "p-limit"; +import type { BatchResetGroup, ResetMutation } from "../../types.js"; +import { computeResetMutation } from "./computeResetMutation.js"; + +const RESET_MUTATION_CONCURRENCY = 25; + +export const computeResetMutations = async ({ + resetGroups, +}: { + resetGroups: BatchResetGroup[]; +}): Promise => { + const limit = pLimit(RESET_MUTATION_CONCURRENCY); + const mutations = await Promise.all( + resetGroups.flatMap((group) => + group.customerEntitlements.map((customerEntitlement) => + limit(() => + computeResetMutation({ + ctx: group.ctx, + customerEntitlement, + }), + ), + ), + ), + ); + + return mutations.filter( + (mutation): mutation is ResetMutation => mutation !== null, + ); +}; diff --git a/server/src/internal/balances/batchReset/compute/computeVerdictMutations.ts b/server/src/internal/balances/batchReset/compute/computeVerdictMutations.ts new file mode 100644 index 0000000000..25382657d3 --- /dev/null +++ b/server/src/internal/balances/batchReset/compute/computeVerdictMutations.ts @@ -0,0 +1,16 @@ +import type { ResetVerdict, VerdictMutations } from "../types.js"; + +export const computeVerdictMutations = ({ + verdicts, +}: { + verdicts: ResetVerdict[]; +}): VerdictMutations => ({ + expireCustomerEntitlementIds: verdicts.flatMap((verdict) => + verdict.kind === "should_expire" ? [verdict.customerEntitlementId] : [], + ), + resetByInvoiceCustomerEntitlementIds: verdicts.flatMap((verdict) => + verdict.kind === "resets_via_invoice" + ? [verdict.customerEntitlementId] + : [], + ), +}); diff --git a/server/src/internal/balances/batchReset/concurrency/batchResetScanGates.ts b/server/src/internal/balances/batchReset/concurrency/batchResetScanGates.ts new file mode 100644 index 0000000000..1b7b91dd3f --- /dev/null +++ b/server/src/internal/balances/batchReset/concurrency/batchResetScanGates.ts @@ -0,0 +1,113 @@ +import { ms } from "@autumn/shared"; +import type { Logger } from "@/external/logtail/logtailUtils.js"; +import { getResetJobV2Config } from "@/internal/misc/resetJobV2/resetJobV2Store.js"; +import { getBatchResetQueueDepth } from "./getBatchResetQueueDepth.js"; +import { sleepWithAbort } from "./sleepWithAbort.js"; + +// SQS counts are approximate — only trust "empty" after consecutive reads. +const CONSECUTIVE_EMPTY_READS_REQUIRED = 2; +const BARRIER_STUCK_WARN_MS = ms.minutes(30); + +/** + * Gate A — in-sweep backpressure. Blocks while the batch reset queue holds + * more than queueHighWaterMessages, so the scanner never runs further ahead + * of the workers than one high-water mark of messages. + * + * Fails open (with a warning) on SQS errors or when no dedicated queue is + * configured — a metrics hiccup should not stop resets. + */ +export const waitForQueueBelowHighWater = async ({ + logger, + signal, +}: { + logger: Logger; + signal: AbortSignal; +}): Promise => { + while (!signal.aborted) { + let depth: Awaited>; + try { + depth = await getBatchResetQueueDepth(); + } catch (error) { + logger.warn( + `[reset-cus-ents-v2] queue depth check failed, scanning without backpressure: ${error}`, + ); + return; + } + + const { queueHighWaterMessages, queueDepthPollMs } = getResetJobV2Config(); + if (!depth || depth.total <= queueHighWaterMessages) return; + + logger.info("[reset-cus-ents-v2] queue above high water, pausing scan", { + jobName: "reset-cus-ents-v2", + data: { + queueVisible: depth.visible, + queueInFlight: depth.inFlight, + highWater: queueHighWaterMessages, + }, + }); + await sleepWithAbort({ delayMs: queueDepthPollMs, signal }); + } +}; + +/** + * Gate B — sweep-restart barrier. Blocks until the batch reset queue is + * fully drained (zero visible AND zero in-flight, on consecutive reads), so + * a new sweep can never re-enqueue rows the workers are still processing. + * + * Never force-restarts: if workers are wedged, restarting would only + * manufacture duplicate work — after BARRIER_STUCK_WARN_MS it logs loudly + * and keeps waiting. Fails open on SQS errors or when no dedicated queue is + * configured. + */ +export const waitForQueueDrained = async ({ + logger, + signal, +}: { + logger: Logger; + signal: AbortSignal; +}): Promise => { + const startedAt = Date.now(); + let consecutiveEmptyReads = 0; + let warnedStuck = false; + + while (!signal.aborted) { + let depth: Awaited>; + try { + depth = await getBatchResetQueueDepth(); + } catch (error) { + logger.warn( + `[reset-cus-ents-v2] queue depth check failed, restarting sweep without barrier: ${error}`, + ); + return; + } + + if (!depth) return; + + if (depth.total === 0) { + consecutiveEmptyReads++; + if (consecutiveEmptyReads >= CONSECUTIVE_EMPTY_READS_REQUIRED) return; + } else { + consecutiveEmptyReads = 0; + } + + if (!warnedStuck && Date.now() - startedAt > BARRIER_STUCK_WARN_MS) { + warnedStuck = true; + logger.error( + "[reset-cus-ents-v2] sweep barrier stuck — workers not draining the batch reset queue", + { + jobName: "reset-cus-ents-v2", + data: { + queueVisible: depth.visible, + queueInFlight: depth.inFlight, + waitedMs: Date.now() - startedAt, + }, + }, + ); + } + + await sleepWithAbort({ + delayMs: getResetJobV2Config().queueDepthPollMs, + signal, + }); + } +}; diff --git a/server/src/internal/balances/batchReset/concurrency/getBatchResetQueueDepth.ts b/server/src/internal/balances/batchReset/concurrency/getBatchResetQueueDepth.ts new file mode 100644 index 0000000000..7549d85a8c --- /dev/null +++ b/server/src/internal/balances/batchReset/concurrency/getBatchResetQueueDepth.ts @@ -0,0 +1,40 @@ +import { GetQueueAttributesCommand } from "@aws-sdk/client-sqs"; +import { getSqsClient } from "@/queue/initSqs.js"; + +export type BatchResetQueueDepth = { + visible: number; + inFlight: number; + total: number; +}; + +/** + * Depth of the dedicated batch reset queue: visible (waiting) plus in-flight + * (being processed) messages. Returns null when no dedicated queue is + * configured — the primary-queue fallback carries unrelated jobs, so its + * counts are meaningless for scan gating. + */ +export const getBatchResetQueueDepth = + async (): Promise => { + const queueUrl = process.env.BATCH_RESET_SQS_QUEUE_URL; + if (!queueUrl) return null; + + const sqsClient = getSqsClient({ queueUrl }); + const response = await sqsClient.send( + new GetQueueAttributesCommand({ + QueueUrl: queueUrl, + AttributeNames: [ + "ApproximateNumberOfMessages", + "ApproximateNumberOfMessagesNotVisible", + ], + }), + ); + + const visible = Number( + response.Attributes?.ApproximateNumberOfMessages ?? 0, + ); + const inFlight = Number( + response.Attributes?.ApproximateNumberOfMessagesNotVisible ?? 0, + ); + + return { visible, inFlight, total: visible + inFlight }; + }; diff --git a/server/src/internal/balances/batchReset/concurrency/sleepWithAbort.ts b/server/src/internal/balances/batchReset/concurrency/sleepWithAbort.ts new file mode 100644 index 0000000000..56a901e40a --- /dev/null +++ b/server/src/internal/balances/batchReset/concurrency/sleepWithAbort.ts @@ -0,0 +1,19 @@ +/** Sleeps for delayMs, resolving early if the signal aborts. */ +export const sleepWithAbort = async ({ + delayMs, + signal, +}: { + delayMs: number; + signal: AbortSignal; +}) => { + if (signal.aborted) return; + await new Promise((resolve) => { + const finish = () => { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + }; + const timer = setTimeout(finish, delayMs); + signal.addEventListener("abort", finish, { once: true }); + }); +}; diff --git a/server/src/internal/balances/batchReset/enqueueBatchResetTask.ts b/server/src/internal/balances/batchReset/enqueueBatchResetTask.ts new file mode 100644 index 0000000000..a4dbc1a646 --- /dev/null +++ b/server/src/internal/balances/batchReset/enqueueBatchResetTask.ts @@ -0,0 +1,30 @@ +import type { Logger } from "@/external/logtail/logtailUtils.js"; +import { JobName } from "@/queue/JobName.js"; +import { addTaskToQueue } from "@/queue/queueUtils.js"; +import type { BatchResetCustomerEntitlementsV2Payload } from "./types.js"; + +/** + * Enqueues a batch reset onto its dedicated SQS queue + * (BATCH_RESET_SQS_QUEUE_URL). Falls back to the primary queue when unset so + * dev environments without the queue still process resets. + */ +export const enqueueBatchResetTask = async ({ + payload, + logger, +}: { + payload: BatchResetCustomerEntitlementsV2Payload; + logger: Logger; +}) => { + const queueUrl = process.env.BATCH_RESET_SQS_QUEUE_URL; + if (!queueUrl) { + logger.warn( + "[batchReset] BATCH_RESET_SQS_QUEUE_URL unset, falling back to primary queue", + ); + } + + await addTaskToQueue({ + jobName: JobName.BatchResetCustomerEntitlementsV2, + payload, + queueUrl, + }); +}; diff --git a/server/src/internal/balances/batchReset/execute/executeResetMutations.ts b/server/src/internal/balances/batchReset/execute/executeResetMutations.ts new file mode 100644 index 0000000000..eef9eda8c5 --- /dev/null +++ b/server/src/internal/balances/batchReset/execute/executeResetMutations.ts @@ -0,0 +1,117 @@ +import { customerEntitlements } from "@autumn/shared"; +import { sql } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { withStatementTimeout } from "@/db/withStatementTimeout.js"; +import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js"; +import { resetCronQueryTag } from "../resetCronQueryTag.js"; +import type { ResetMutation } from "../types.js"; + +/** + * Applies the balance updates with an optimistic guard: a row only updates + * while its next_reset_at still matches what the mutation was computed from. + * A row that changed in between (a lazy reset racing the worker, or a + * duplicate delivery) is skipped. Returns the IDs that actually applied. + */ +const updateCustomerEntitlements = async ({ + db, + resetMutations, +}: { + db: DrizzleCli; + resetMutations: ResetMutation[]; +}): Promise> => { + const updates = resetMutations.map( + ({ customerEntitlementId, expectedNextResetAt, updates }) => ({ + id: customerEntitlementId, + expected_next_reset_at: expectedNextResetAt, + ...updates, + }), + ); + + const appliedRows = await db.execute<{ id: string }>(sql` + UPDATE ${customerEntitlements} AS customer_entitlement + SET + balance = COALESCE(reset_update.balance, customer_entitlement.balance), + additional_balance = COALESCE( + reset_update.additional_balance, + customer_entitlement.additional_balance + ), + adjustment = reset_update.adjustment, + entities = COALESCE(reset_update.entities, customer_entitlement.entities), + next_reset_at = reset_update.next_reset_at, + cache_version = COALESCE(customer_entitlement.cache_version, 0) + 1 + FROM jsonb_to_recordset(${JSON.stringify(updates)}::jsonb) AS reset_update( + id text, + expected_next_reset_at numeric, + balance numeric, + additional_balance numeric, + adjustment numeric, + entities jsonb, + next_reset_at numeric + ) + WHERE customer_entitlement.id = reset_update.id + AND customer_entitlement.next_reset_at = reset_update.expected_next_reset_at + RETURNING customer_entitlement.id + ${resetCronQueryTag("updateBalances")} + `); + + return new Set(appliedRows.map((row) => row.id)); +}; + +export const executeResetMutations = async ({ + db, + resetMutations, +}: { + db: DrizzleCli; + resetMutations: ResetMutation[]; +}): Promise<{ + appliedCustomerEntitlementIds: Set; + staleSkippedCount: number; +}> => { + if (resetMutations.length === 0) { + return { appliedCustomerEntitlementIds: new Set(), staleSkippedCount: 0 }; + } + + let appliedCustomerEntitlementIds = new Set(); + + await withStatementTimeout(db, async (transaction) => { + appliedCustomerEntitlementIds = await updateCustomerEntitlements({ + db: transaction, + resetMutations, + }); + + // Rollover writes only for rows whose guarded UPDATE applied — a stale + // mutation's rollover would double-credit a row someone else already + // reset. + const appliedMutations = resetMutations.filter( + ({ customerEntitlementId }) => + appliedCustomerEntitlementIds.has(customerEntitlementId), + ); + + const rolloverWrites = appliedMutations.flatMap( + ({ rolloverInserts, rolloverUpdates }) => [ + ...rolloverInserts, + ...rolloverUpdates, + ], + ); + const rolloverDeleteIds = appliedMutations.flatMap( + ({ rolloverDeleteIds }) => rolloverDeleteIds, + ); + + await RolloverService.upsert({ + db: transaction, + rows: rolloverWrites, + queryTag: resetCronQueryTag("upsertRollovers"), + }); + await RolloverService.delete({ + db: transaction, + ids: rolloverDeleteIds, + queryTag: resetCronQueryTag("deleteRollovers"), + }); + }); + + return { + appliedCustomerEntitlementIds, + staleSkippedCount: + resetMutations.length - appliedCustomerEntitlementIds.size, + }; +}; diff --git a/server/src/internal/balances/batchReset/execute/executeVerdictMutations.ts b/server/src/internal/balances/batchReset/execute/executeVerdictMutations.ts new file mode 100644 index 0000000000..17b0b04d59 --- /dev/null +++ b/server/src/internal/balances/batchReset/execute/executeVerdictMutations.ts @@ -0,0 +1,36 @@ +import { customerEntitlements } from "@autumn/shared"; +import { sql } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { resetCronQueryTag } from "../resetCronQueryTag.js"; +import type { VerdictMutations } from "../types.js"; + +export const executeVerdictMutations = async ({ + db, + verdictMutations, +}: { + db: DrizzleCli; + verdictMutations: VerdictMutations; +}) => { + const { expireCustomerEntitlementIds, resetByInvoiceCustomerEntitlementIds } = + verdictMutations; + + if (expireCustomerEntitlementIds.length > 0) { + await db.execute(sql` + UPDATE ${customerEntitlements} + SET expired = true + WHERE id = ANY(${sql.param(expireCustomerEntitlementIds)}::text[]) + AND expired IS NOT TRUE + ${resetCronQueryTag("expireEntitlements")} + `); + } + + if (resetByInvoiceCustomerEntitlementIds.length > 0) { + await db.execute(sql` + UPDATE ${customerEntitlements} + SET reset_by_invoice = true + WHERE id = ANY(${sql.param(resetByInvoiceCustomerEntitlementIds)}::text[]) + AND reset_by_invoice IS NOT TRUE + ${resetCronQueryTag("markInvoiceReset")} + `); + } +}; diff --git a/server/src/internal/balances/batchReset/execute/invalidateResetCaches.ts b/server/src/internal/balances/batchReset/execute/invalidateResetCaches.ts new file mode 100644 index 0000000000..87a91bcc0d --- /dev/null +++ b/server/src/internal/balances/batchReset/execute/invalidateResetCaches.ts @@ -0,0 +1,62 @@ +import type { Redis } from "ioredis"; +import { resolveCustomerRedisRouting } from "@/external/redis/customerRedisRouting.js"; +import type { CustomerEntitlementBalanceInvalidation } from "@/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCustomerEntitlementBalances.js"; +import { batchInvalidateCustomerEntitlementBalances } from "@/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCustomerEntitlementBalances.js"; +import type { BatchResetGroup, ResetMutation } from "../types.js"; + +/** + * Drops the V2 shared subject-balance hash state for every customer + * entitlement a reset mutation touched: one HDEL pipeline (cusEnt field + + * aggregated field) per routed redis client. Pipelines never span groups, so + * each stays org-scoped (cluster slot safety). + * + * Deletion, not patching — the next read rehydrates from Postgres. The + * legacy FullCustomer cache is dead and intentionally not touched. + */ +export const invalidateResetCaches = async ({ + resetGroups, + resetMutations, +}: { + resetGroups: BatchResetGroup[]; + resetMutations: ResetMutation[]; +}) => { + const mutatedIds = new Set( + resetMutations.map(({ customerEntitlementId }) => customerEntitlementId), + ); + if (mutatedIds.size === 0) return; + + for (const group of resetGroups) { + const invalidationsByRedis = new Map< + Redis, + CustomerEntitlementBalanceInvalidation[] + >(); + + for (const customerEntitlement of group.customerEntitlements) { + if (!mutatedIds.has(customerEntitlement.id)) continue; + + const customer = customerEntitlement.customer; + if (!customer.id) continue; + + const routing = resolveCustomerRedisRouting({ + org: group.ctx.org, + customerId: customer.id, + }); + const invalidations = invalidationsByRedis.get(routing.redis) ?? []; + invalidations.push({ + orgId: customer.org_id, + env: customer.env, + customerId: customer.id, + featureId: customerEntitlement.entitlement.feature.id, + customerEntitlementId: customerEntitlement.id, + }); + invalidationsByRedis.set(routing.redis, invalidations); + } + + for (const [redisV2, invalidations] of invalidationsByRedis) { + await batchInvalidateCustomerEntitlementBalances({ + redisV2, + invalidations, + }); + } + } +}; diff --git a/server/src/internal/balances/batchReset/logs/logBatchCustomerEntitlementsV2.ts b/server/src/internal/balances/batchReset/logs/logBatchCustomerEntitlementsV2.ts new file mode 100644 index 0000000000..37210c2dd9 --- /dev/null +++ b/server/src/internal/balances/batchReset/logs/logBatchCustomerEntitlementsV2.ts @@ -0,0 +1,43 @@ +import type { Logger } from "@/external/logtail/logtailUtils.js"; +import type { ClassifiedBatchResetContext } from "../compute/classifyBatchResetContext.js"; +import type { + BatchResetContext, + BatchResetCustomerEntitlementsV2Payload, + ResetMutation, + VerdictMutations, +} from "../types.js"; + +export const logBatchCustomerEntitlementsV2 = ({ + logger, + payload, + batchResetContext, + classifiedBatchResetContext, + verdictMutations, + resetMutations, +}: { + logger: Logger; + payload: BatchResetCustomerEntitlementsV2Payload; + batchResetContext: BatchResetContext; + classifiedBatchResetContext: ClassifiedBatchResetContext; + verdictMutations: VerdictMutations; + resetMutations: ResetMutation[]; +}) => { + const resettableCount = classifiedBatchResetContext.resetGroups.reduce( + (total, group) => total + group.customerEntitlements.length, + 0, + ); + + logger.info("[batchReset] mutations computed", { + data: { + requested: payload.customerEntitlementIds.length, + missing: batchResetContext.missingIds.length, + orgs: batchResetContext.groups.length, + resetOrgs: classifiedBatchResetContext.resetGroups.length, + resettable: resettableCount, + customerEntitlementsToExpire: + verdictMutations.expireCustomerEntitlementIds.length, + resetMutations: resetMutations.length, + verdicts: classifiedBatchResetContext.verdicts.length, + }, + }); +}; diff --git a/server/src/internal/balances/batchReset/logs/logResetBacklogGauge.ts b/server/src/internal/balances/batchReset/logs/logResetBacklogGauge.ts new file mode 100644 index 0000000000..09a7492235 --- /dev/null +++ b/server/src/internal/balances/batchReset/logs/logResetBacklogGauge.ts @@ -0,0 +1,45 @@ +import type { CronContext } from "@/cron/utils/CronContext.js"; +import type { ResetScanCursor } from "@/internal/customers/cusProducts/cusEnts/repos/getResetEligibleCustomerEntitlementsPage.js"; +import { customerEntitlementsRepo } from "@/internal/customers/cusProducts/cusEnts/repos/index.js"; +import { getBatchResetQueueDepth } from "../concurrency/getBatchResetQueueDepth.js"; + +// Cap keeps the count cheap against a large backlog ("at least N"). +const BACKLOG_COUNT_CAP = 1_000_000; + +/** + * Periodic status event for Axiom: how many customer entitlements are + * overdue-eligible right now, current queue depth, and how far behind "now" + * the scan position is. + */ +export const logResetBacklogGauge = async ({ + ctx, + cursor, + sweepScanned, +}: { + ctx: CronContext; + cursor: ResetScanCursor | null; + sweepScanned: number; +}) => { + const [backlog, queueDepth] = await Promise.all([ + customerEntitlementsRepo.countResetEligibleCustomerEntitlements({ + db: ctx.db, + dueBefore: Date.now(), + cap: BACKLOG_COUNT_CAP, + }), + getBatchResetQueueDepth().catch(() => null), + ]); + + ctx.logger.info("[reset-cus-ents-v2] backlog gauge", { + jobName: "reset-cus-ents-v2", + data: { + overdueEligibleCount: backlog.count, + overdueCountCapped: backlog.capped, + queueVisible: queueDepth?.visible ?? null, + queueInFlight: queueDepth?.inFlight ?? null, + sweepScanned, + cursorResetAt: cursor?.nextResetAt ?? null, + // How far behind "now" the scan position is, in ms. + scanLagMs: cursor ? Date.now() - cursor.nextResetAt : null, + }, + }); +}; diff --git a/server/src/internal/balances/batchReset/resetCronQueryTag.ts b/server/src/internal/balances/batchReset/resetCronQueryTag.ts new file mode 100644 index 0000000000..328cab633a --- /dev/null +++ b/server/src/internal/balances/batchReset/resetCronQueryTag.ts @@ -0,0 +1,16 @@ +import { planetScaleTag } from "@/db/dbUtils.js"; + +const RESET_CRON_WORKLOAD = "resetCronV2"; + +type ResetCronQuery = + | "countEligible" + | "deleteRollovers" + | "expireEntitlements" + | "hydrateContext" + | "markInvoiceReset" + | "scanEligible" + | "updateBalances" + | "upsertRollovers"; + +export const resetCronQueryTag = (query: ResetCronQuery) => + planetScaleTag({ query: `reset:${query}`, workload: RESET_CRON_WORKLOAD }); diff --git a/server/src/internal/balances/batchReset/runResetLoopV2.ts b/server/src/internal/balances/batchReset/runResetLoopV2.ts new file mode 100644 index 0000000000..9094e718fa --- /dev/null +++ b/server/src/internal/balances/batchReset/runResetLoopV2.ts @@ -0,0 +1,213 @@ +import { ms } from "@autumn/shared"; +import * as Sentry from "@sentry/bun"; +import type { CronContext } from "@/cron/utils/CronContext.js"; +import type { + ResetEligibleCustomerEntitlementRow, + ResetScanCursor, +} from "@/internal/customers/cusProducts/cusEnts/repos/getResetEligibleCustomerEntitlementsPage.js"; +import { customerEntitlementsRepo } from "@/internal/customers/cusProducts/cusEnts/repos/index.js"; +import { + getResetJobV2Config, + isResetJobV2Enabled, +} from "@/internal/misc/resetJobV2/resetJobV2Store.js"; +import { isActiveSlot } from "@/queue/blueGreen/blueGreenGate.js"; +import { + waitForQueueBelowHighWater, + waitForQueueDrained, +} from "./concurrency/batchResetScanGates.js"; +import { sleepWithAbort } from "./concurrency/sleepWithAbort.js"; +import { enqueueBatchResetTask } from "./enqueueBatchResetTask.js"; +import { logResetBacklogGauge } from "./logs/logResetBacklogGauge.js"; +import type { BatchResetCustomerEntitlementsV2Payload } from "./types.js"; + +const JOB_NAME = "reset-cus-ents-v2"; +const IDLE_DELAY_MS = ms.seconds(5); +const BACKLOG_GAUGE_INTERVAL_MS = ms.minutes(5); + +/** Progress of the sweep currently in flight. */ +type SweepState = { + cursor: ResetScanCursor | null; + dueBefore: number | null; + startedAt: number | null; + pages: number; + scanned: number; + messages: number; +}; + +const newSweepState = (): SweepState => ({ + cursor: null, + dueBefore: null, + startedAt: null, + pages: 0, + scanned: 0, + messages: 0, +}); + +/** Chunks one scanned page into compact ID-only SQS payloads. */ +export const pageToBatchResetPayloads = ({ + page, + workerBatchSize, +}: { + page: ResetEligibleCustomerEntitlementRow[]; + workerBatchSize: number; +}): BatchResetCustomerEntitlementsV2Payload[] => { + const payloads: BatchResetCustomerEntitlementsV2Payload[] = []; + + for (let i = 0; i < page.length; i += workerBatchSize) { + payloads.push({ + customerEntitlementIds: page + .slice(i, i + workerBatchSize) + .map((row) => row.id), + }); + } + return payloads; +}; + +const shouldRunPage = () => + process.env.DISABLE_CRON !== "true" && + isResetJobV2Enabled() && + isActiveSlot({ serviceName: "cron" }); + +/** Enqueues one scanned page and folds it into the sweep counters. */ +const enqueuePage = async ({ + ctx, + page, + sweep, + workerBatchSize, +}: { + ctx: CronContext; + page: ResetEligibleCustomerEntitlementRow[]; + sweep: SweepState; + workerBatchSize: number; +}) => { + const payloads = pageToBatchResetPayloads({ page, workerBatchSize }); + for (const payload of payloads) { + await enqueueBatchResetTask({ payload, logger: ctx.logger }); + } + + const lastRow = page[page.length - 1]; + sweep.cursor = { nextResetAt: lastRow.nextResetAt, id: lastRow.id }; + sweep.pages++; + sweep.scanned += page.length; + sweep.messages += payloads.length; + + ctx.logger.info("[reset-cus-ents-v2] page enqueued", { + jobName: JOB_NAME, + data: { + fetched: page.length, + messages: payloads.length, + dueBefore: sweep.dueBefore, + sweepPages: sweep.pages, + sweepScanned: sweep.scanned, + cursorResetAt: lastRow.nextResetAt, + cursorId: lastRow.id, + }, + }); +}; + +/** Gate B + completion event: wait for the workers to drain this sweep's + * messages, then report it (idle sweeps that scanned nothing stay silent). */ +const completeSweep = async ({ + ctx, + signal, + sweep, +}: { + ctx: CronContext; + signal: AbortSignal; + sweep: SweepState; +}) => { + const barrierStartedAt = Date.now(); + await waitForQueueDrained({ logger: ctx.logger, signal }); + + if (sweep.scanned === 0 || sweep.startedAt === null) return; + + ctx.logger.info("[reset-cus-ents-v2] sweep completed", { + jobName: JOB_NAME, + data: { + sweepPages: sweep.pages, + sweepScanned: sweep.scanned, + sweepMessages: sweep.messages, + sweepDurationMs: Date.now() - sweep.startedAt, + drainWaitMs: Date.now() - barrierStartedAt, + dueBefore: sweep.dueBefore, + }, + }); +}; + +/** + * V2 reset cron: a continuous keyset sweep over overdue customer entitlements, + * fanned out to the batch reset queue. + * + * Flow per iteration: backlog gauge (rate-limited) -> Gate A (queue + * backpressure) -> scan one page -> enqueue. When a sweep drains, Gate B + * waits for the workers to finish before the cursor rewinds, so a new sweep + * can never re-enqueue rows that are still queued or in flight. + */ +export const runResetLoopV2 = async ({ + ctx, + signal, +}: { + ctx: CronContext; + signal: AbortSignal; +}) => { + let sweep = newSweepState(); + let lastGaugeAt = 0; + + while (!signal.aborted) { + let delayMs = IDLE_DELAY_MS; + + if (shouldRunPage()) { + try { + const { scanBatchSize, workerBatchSize, scanIntervalMs } = + getResetJobV2Config(); + + if (Date.now() - lastGaugeAt >= BACKLOG_GAUGE_INTERVAL_MS) { + lastGaugeAt = Date.now(); + await logResetBacklogGauge({ + ctx, + cursor: sweep.cursor, + sweepScanned: sweep.scanned, + }); + } + + // Gate A: don't scan further ahead than the workers can chew. + await waitForQueueBelowHighWater({ logger: ctx.logger, signal }); + if (signal.aborted) break; + + sweep.dueBefore ??= Date.now(); + sweep.startedAt ??= Date.now(); + const page = + await customerEntitlementsRepo.getResetEligibleCustomerEntitlementsPage( + { + db: ctx.db, + dueBefore: sweep.dueBefore, + cursor: sweep.cursor, + limit: scanBatchSize, + }, + ); + + if (page.length > 0) { + await enqueuePage({ ctx, page, sweep, workerBatchSize }); + } + + if (page.length === scanBatchSize) { + delayMs = scanIntervalMs; + } else { + await completeSweep({ ctx, signal, sweep }); + sweep = newSweepState(); + } + } catch (error) { + sweep = newSweepState(); + ctx.logger.error("[reset-cus-ents-v2] page failed", { + jobName: JOB_NAME, + err: error, + }); + Sentry.captureException(error, { + extra: { context: "runResetLoopV2.fetchPage" }, + }); + } + } + + await sleepWithAbort({ delayMs, signal }); + } +}; diff --git a/server/src/internal/balances/batchReset/setup/setupBatchResetContext.ts b/server/src/internal/balances/batchReset/setup/setupBatchResetContext.ts new file mode 100644 index 0000000000..8ae12e4346 --- /dev/null +++ b/server/src/internal/balances/batchReset/setup/setupBatchResetContext.ts @@ -0,0 +1,101 @@ +import type { AppEnv } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import type { Logger } from "@/external/logtail/logtailUtils.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { ResetContextCustomerEntitlement } from "@/internal/customers/cusProducts/cusEnts/repos/getResetContextByIds.js"; +import { customerEntitlementsRepo } from "@/internal/customers/cusProducts/cusEnts/repos/index.js"; +import { createWorkerContext } from "@/queue/createWorkerContext.js"; +import type { + BatchResetContext, + BatchResetCustomerEntitlementsV2Payload, + BatchResetGroup, +} from "../types.js"; + +const orgEnvKey = ({ orgId, env }: { orgId: string; env: string }) => + `${orgId}:${env}`; + +/** + * Builds one worker context per unique org+env in the batch (each context + * loads the org + features once). Orgs deleted since queueing are skipped. + */ +const fetchUniqueOrgContexts = async ({ + db, + logger, + customerEntitlements, +}: { + db: DrizzleCli; + logger: Logger; + customerEntitlements: ResetContextCustomerEntitlement[]; +}): Promise> => { + const uniqueOrgEnvs = new Map(); + for (const customerEntitlement of customerEntitlements) { + const { org_id: orgId, env } = customerEntitlement.customer; + uniqueOrgEnvs.set(orgEnvKey({ orgId, env }), { + orgId, + env: env as AppEnv, + }); + } + + const contexts = new Map(); + for (const [key, { orgId, env }] of uniqueOrgEnvs) { + const ctx = await createWorkerContext({ + db, + payload: { orgId, env }, + logger, + }); + if (!ctx) { + logger.warn(`[batchReset] org ${orgId} (${env}) not found, skipping`); + continue; + } + contexts.set(key, ctx); + } + + return contexts; +}; + +/** + * Setup for a batch reset: hydrates the requested customer entitlements, then + * groups them per org+env with one worker context per group. + */ +export const setupBatchResetContext = async ({ + db, + logger, + payload, +}: { + db: DrizzleCli; + logger: Logger; + payload: BatchResetCustomerEntitlementsV2Payload; +}): Promise => { + const { customerEntitlements, missingIds } = + await customerEntitlementsRepo.getResetContextByIds({ + db, + customerEntitlementIds: payload.customerEntitlementIds, + }); + + const orgContexts = await fetchUniqueOrgContexts({ + db, + logger, + customerEntitlements, + }); + + const groupsByOrgEnv = new Map(); + for (const customerEntitlement of customerEntitlements) { + const { org_id: orgId, env } = customerEntitlement.customer; + const key = orgEnvKey({ orgId, env }); + + const ctx = orgContexts.get(key); + if (!ctx) continue; + + const group = groupsByOrgEnv.get(key) ?? { + ctx, + customerEntitlements: [], + }; + group.customerEntitlements.push(customerEntitlement); + groupsByOrgEnv.set(key, group); + } + + return { + groups: [...groupsByOrgEnv.values()], + missingIds, + }; +}; diff --git a/server/src/internal/balances/batchReset/types.ts b/server/src/internal/balances/batchReset/types.ts new file mode 100644 index 0000000000..fb8ce57463 --- /dev/null +++ b/server/src/internal/balances/batchReset/types.ts @@ -0,0 +1,56 @@ +import type { Rollover } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { ResetUpdates } from "@/internal/customers/actions/resetCustomerEntitlements/processReset.js"; +import type { ResetContextCustomerEntitlement } from "@/internal/customers/cusProducts/cusEnts/repos/getResetContextByIds.js"; + +export type BatchResetCustomerEntitlementsV2Payload = { + customerEntitlementIds: string[]; +}; + +export type BatchResetGroup = { + ctx: AutumnContext; + customerEntitlements: ResetContextCustomerEntitlement[]; +}; + +export type BatchResetContext = { + /** One group per unique org+env found in the batch, each with its own + * worker context (org + features loaded once per group). */ + groups: BatchResetGroup[]; + missingIds: string[]; +}; + +/** Every non-resettable candidate receives exactly one verdict. */ +export type ResetVerdict = { + kind: + | "resets_via_invoice" + | "should_expire" + | "clear_next_reset" + | "no_action"; + customerEntitlementId: string; + reason?: + | "product_past_due" + | "product_not_active" + | "not_due" + | "pooled_balance_source" + | "pooled_balance_missing"; + unlimited?: boolean; +}; + +export type ResetMutation = { + customerEntitlementId: string; + /** next_reset_at the mutation was computed from. The execute UPDATE only + * applies while the row still matches (optimistic guard), so a concurrent + * duplicate — a lazy reset racing the worker, or an SQS redelivery — + * no-ops instead of double-applying balances and rollovers. */ + expectedNextResetAt: number; + updates: ResetUpdates; + rolloverInserts: Rollover[]; + rolloverUpdates: Rollover[]; + rolloverDeleteIds: string[]; +}; + +export type VerdictMutations = { + expireCustomerEntitlementIds: string[]; + /** Price-backed on a live subscription: flag so the scan skips them. */ + resetByInvoiceCustomerEntitlementIds: string[]; +}; diff --git a/server/src/internal/balances/finalizeLock/runRedisFinalizeLockV2.ts b/server/src/internal/balances/finalizeLock/runRedisFinalizeLockV2.ts index 7f84b3f13c..8692fe3cb2 100644 --- a/server/src/internal/balances/finalizeLock/runRedisFinalizeLockV2.ts +++ b/server/src/internal/balances/finalizeLock/runRedisFinalizeLockV2.ts @@ -5,6 +5,7 @@ import type { FinalizeLockContextV2 } from "@/internal/balances/utils/lockV2/bui import { deductionUpdatesToModifiedIds } from "@/internal/balances/utils/sync/deductionUpdatesToModifiedIds.js"; import { globalSyncBatchingManagerV3 } from "@/internal/balances/utils/sync/SyncBatchingManagerV3.js"; import { RedisDeductionError } from "@/internal/balances/utils/types/redisDeductionError.js"; +import { isSyncCoalesceEnabled } from "@/internal/misc/miscellaneousEdgeConfig/miscellaneousEdgeConfigStore.js"; import { insertFinalizeLockEventV2 } from "./insertFinalizeLockEventV2.js"; import { runPostgresFinalizeLockV2 } from "./runPostgresFinalizeLockV2.js"; @@ -65,6 +66,9 @@ export const runRedisFinalizeLockV2 = async ({ entityId: receipt.entity_id ?? undefined, modifiedCusEntIdsByFeatureId, usageWindowUpdates, + coalesce: ctx.testOptions?.syncCoalesce ?? isSyncCoalesceEnabled(), + // The drain resolves via worker ctx, so the mark must use the same routing. + coalesceRedis: ctx.redisV2, }); } diff --git a/server/src/internal/balances/handlers/handleTrack.ts b/server/src/internal/balances/handlers/handleTrack.ts index 2464c7e896..ca1b5ac7c7 100644 --- a/server/src/internal/balances/handlers/handleTrack.ts +++ b/server/src/internal/balances/handlers/handleTrack.ts @@ -9,6 +9,7 @@ import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { runAsyncTrack } from "@/internal/balances/track/runAsyncTrack.js"; import { runTrackWithRollout } from "@/internal/balances/track/runTrackWithRollout.js"; import { getTrackFeatureDeductionsForBody } from "@/internal/balances/track/utils/getFeatureDeductions.js"; +import { getQueuedTrackResponse } from "@/internal/balances/track/utils/getQueuedTrackResponse.js"; export const handleTrack = createRoute({ scopes: [Scopes.Balances.Write], @@ -23,9 +24,9 @@ export const handleTrack = createRoute({ const ctx = c.get("ctx"); const featureDeductions = getTrackFeatureDeductionsForBody({ ctx, body }); - if (body.async === true) { + if (body.async === true || ctx.org.slug === "firecrawl") { await runAsyncTrack({ ctx, body }); - return c.json({ success: true }, 202); + return c.json(getQueuedTrackResponse({ ctx, body }), 202); } const response = await runTrackWithRollout({ diff --git a/server/src/internal/balances/handlers/handleTrackTokens.ts b/server/src/internal/balances/handlers/handleTrackTokens.ts index 19db45e759..6480da81fd 100644 --- a/server/src/internal/balances/handlers/handleTrackTokens.ts +++ b/server/src/internal/balances/handlers/handleTrackTokens.ts @@ -6,6 +6,7 @@ import { import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { runAsyncTrack } from "@/internal/balances/track/runAsyncTrack.js"; import { runTrackWithRollout } from "@/internal/balances/track/runTrackWithRollout.js"; +import { getQueuedTrackResponse } from "@/internal/balances/track/utils/getQueuedTrackResponse.js"; import { getTokenTrackParams } from "@/internal/balances/track/utils/getTokenTrackParams.js"; export const handleTrackTokens = createRoute({ @@ -23,7 +24,7 @@ export const handleTrackTokens = createRoute({ if (trackBody.async === true) { await runAsyncTrack({ ctx, body: trackBody }); - return c.json({ success: true }, 202); + return c.json(getQueuedTrackResponse({ ctx, body: trackBody }), 202); } const response = await runTrackWithRollout({ diff --git a/server/src/internal/balances/track/runAsyncTrack.ts b/server/src/internal/balances/track/runAsyncTrack.ts index a959bfea67..a02e47e7ee 100644 --- a/server/src/internal/balances/track/runAsyncTrack.ts +++ b/server/src/internal/balances/track/runAsyncTrack.ts @@ -1,5 +1,6 @@ import { ErrCode, RecaseError, type TrackParams } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { getAsyncTrackMessageGroupId } from "./utils/getAsyncTrackMessageGroupId.js"; import { queueTrack } from "./utils/queueTrack.js"; const ASYNC_TRACK_UNAVAILABLE_MESSAGE = @@ -23,11 +24,20 @@ export const runAsyncTrack = async ({ statusCode: 503, }); } + const messageDeduplicationId = ctx.id; const queued = await queueTrack({ ctx, body, queueUrl, + messageGroupId: getAsyncTrackMessageGroupId({ + orgId: ctx.org.id, + env: ctx.env, + customerId: body.customer_id, + entityId: body.entity_id, + messageDeduplicationId, + }), + messageDeduplicationId, logFallback: false, markQueuedForReplay: false, }); diff --git a/server/src/internal/balances/track/runBatchTrack.ts b/server/src/internal/balances/track/runBatchTrack.ts index 091e4e2023..a518f97ec3 100644 --- a/server/src/internal/balances/track/runBatchTrack.ts +++ b/server/src/internal/balances/track/runBatchTrack.ts @@ -2,6 +2,7 @@ import { type BatchTrackParams, ErrCode, RecaseError } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { JobName } from "@/queue/JobName.js"; import { addTasksToQueueBatch } from "@/queue/queueUtils.js"; +import { getAsyncTrackMessageGroupId } from "./utils/getAsyncTrackMessageGroupId.js"; import { getTrackFeatureDeductionsForBody } from "./utils/getFeatureDeductions.js"; const ASYNC_TRACK_UNAVAILABLE_MESSAGE = @@ -31,19 +32,29 @@ export const runBatchTrack = async ({ getTrackFeatureDeductionsForBody({ ctx, body: item }); } - const entries = body.map((item, index) => ({ - payload: { - orgId: ctx.org.id, - env: ctx.env, - customerId: item.customer_id, - entityId: item.entity_id, - requestId: ctx.id, - apiVersion: ctx.apiVersion.value, - body: item, - }, - messageGroupId: `${ctx.org.id}:${ctx.env}:${item.customer_id}:${item.entity_id ?? "none"}`, - messageDeduplicationId: `${ctx.id}-${index}`, - })); + const entries = body.map((item, index) => { + const messageDeduplicationId = `${ctx.id}-${index}`; + + return { + payload: { + orgId: ctx.org.id, + env: ctx.env, + customerId: item.customer_id, + entityId: item.entity_id, + requestId: ctx.id, + apiVersion: ctx.apiVersion.value, + body: item, + }, + messageGroupId: getAsyncTrackMessageGroupId({ + orgId: ctx.org.id, + env: ctx.env, + customerId: item.customer_id, + entityId: item.entity_id, + messageDeduplicationId, + }), + messageDeduplicationId, + }; + }); const { successCount, failures } = await addTasksToQueueBatch({ jobName: JobName.Track, diff --git a/server/src/internal/balances/track/utils/getAsyncTrackMessageGroupId.ts b/server/src/internal/balances/track/utils/getAsyncTrackMessageGroupId.ts new file mode 100644 index 0000000000..07fde1798f --- /dev/null +++ b/server/src/internal/balances/track/utils/getAsyncTrackMessageGroupId.ts @@ -0,0 +1,22 @@ +import type { AppEnv } from "@autumn/shared"; + +const ASYNC_TRACK_SHARD_COUNT = 8n; + +export const getAsyncTrackMessageGroupId = ({ + orgId, + env, + customerId, + entityId, + messageDeduplicationId, +}: { + orgId: string; + env: AppEnv; + customerId: string; + entityId?: string; + messageDeduplicationId: string; +}) => { + const shard = + BigInt(Bun.hash(messageDeduplicationId)) % ASYNC_TRACK_SHARD_COUNT; + + return `${orgId}:${env}:${customerId}:${entityId ?? "none"}:shard-${shard}`; +}; diff --git a/server/src/internal/balances/track/utils/queueTrack.ts b/server/src/internal/balances/track/utils/queueTrack.ts index 8f44666eb2..1ad5526c8e 100644 --- a/server/src/internal/balances/track/utils/queueTrack.ts +++ b/server/src/internal/balances/track/utils/queueTrack.ts @@ -9,6 +9,7 @@ export const queueTrack = async ({ ctx, body, queueUrl, + messageGroupId, messageDeduplicationId, logFallback = true, markQueuedForReplay = true, @@ -16,6 +17,7 @@ export const queueTrack = async ({ ctx: AutumnContext; body: TrackParams; queueUrl?: string; + messageGroupId?: string; messageDeduplicationId?: string; logFallback?: boolean; markQueuedForReplay?: boolean; @@ -32,7 +34,9 @@ export const queueTrack = async ({ await addTaskToQueue({ jobName: JobName.Track, queueUrl: resolvedQueueUrl, - messageGroupId: `${ctx.org.id}:${ctx.env}:${body.customer_id}:${body.entity_id ?? "none"}`, + messageGroupId: + messageGroupId ?? + `${ctx.org.id}:${ctx.env}:${body.customer_id}:${body.entity_id ?? "none"}`, messageDeduplicationId: messageDeduplicationId ?? ctx.id, payload: { orgId: ctx.org.id, diff --git a/server/src/internal/balances/track/v3/runRedisTrackV3.ts b/server/src/internal/balances/track/v3/runRedisTrackV3.ts index 206c6f4dbf..b79e7f3dc2 100644 --- a/server/src/internal/balances/track/v3/runRedisTrackV3.ts +++ b/server/src/internal/balances/track/v3/runRedisTrackV3.ts @@ -18,6 +18,7 @@ import { projectMutationLogsToTrackDeductionsV2, } from "@/internal/balances/utils/deductionV2/index.js"; import { globalSyncBatchingManagerV3 } from "@/internal/balances/utils/sync/SyncBatchingManagerV3.js"; +import { isSyncCoalesceEnabled } from "@/internal/misc/miscellaneousEdgeConfig/miscellaneousEdgeConfigStore.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; import type { RolloverUpdate } from "../../utils/types/rolloverUpdate.js"; import type { UsageWindowUpdate } from "../../utils/types/usageWindowUpdate.js"; @@ -59,6 +60,8 @@ const queueSyncItem = ({ entityId: fullSubject.entityId, modifiedCusEntIdsByFeatureId, usageWindowUpdates, + coalesce: ctx.testOptions?.syncCoalesce ?? isSyncCoalesceEnabled(), + coalesceRedis: ctx.redisV2, }); }; diff --git a/server/src/internal/balances/track/v3/trackIdempotencyKey.ts b/server/src/internal/balances/track/v3/trackIdempotencyKey.ts index 3fb04a374d..cc9e4a656e 100644 --- a/server/src/internal/balances/track/v3/trackIdempotencyKey.ts +++ b/server/src/internal/balances/track/v3/trackIdempotencyKey.ts @@ -1,7 +1,7 @@ import { ms } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -export const TRACK_V3_IDEMPOTENCY_TTL_MS = ms.days(2); +export const TRACK_V3_IDEMPOTENCY_TTL_MS = ms.days(1); export const getTrackIdempotencyKey = ({ ctx }: { ctx: AutumnContext }) => `track:${ctx.id}`; diff --git a/server/src/internal/balances/utils/deductionV2/applyDeductionUpdateToFullSubject.ts b/server/src/internal/balances/utils/deductionV2/applyDeductionUpdateToFullSubject.ts index 4a4fe11a71..0794c2605f 100644 --- a/server/src/internal/balances/utils/deductionV2/applyDeductionUpdateToFullSubject.ts +++ b/server/src/internal/balances/utils/deductionV2/applyDeductionUpdateToFullSubject.ts @@ -91,4 +91,19 @@ export const applyDeductionUpdateToFullSubject = ({ }); return; } + + for ( + let index = 0; + index < fullSubject.pooled_customer_entitlements.length; + index++ + ) { + const customerEntitlement = fullSubject.pooled_customer_entitlements[index]; + if (customerEntitlement.id !== customerEntitlementId) continue; + + fullSubject.pooled_customer_entitlements[index] = applyUpdate({ + customerEntitlement, + update, + }); + return; + } }; diff --git a/server/src/internal/balances/utils/deductionV2/applyRolloverUpdatesToFullSubject.ts b/server/src/internal/balances/utils/deductionV2/applyRolloverUpdatesToFullSubject.ts index de0148e2c6..b16dd637ed 100644 --- a/server/src/internal/balances/utils/deductionV2/applyRolloverUpdatesToFullSubject.ts +++ b/server/src/internal/balances/utils/deductionV2/applyRolloverUpdatesToFullSubject.ts @@ -42,4 +42,9 @@ export const applyRolloverUpdatesToFullSubject = ({ fullSubjectCustomerEntitlements: fullSubject.extra_customer_entitlements, rolloverUpdates, }); + + applyRolloverUpdate({ + fullSubjectCustomerEntitlements: fullSubject.pooled_customer_entitlements, + rolloverUpdates, + }); }; diff --git a/server/src/internal/balances/utils/sql/syncBalancesV2.sql b/server/src/internal/balances/utils/sql/syncBalancesV2.sql index 524433714c..574271e0e1 100644 --- a/server/src/internal/balances/utils/sql/syncBalancesV2.sql +++ b/server/src/internal/balances/utils/sql/syncBalancesV2.sql @@ -150,7 +150,12 @@ BEGIN balance = COALESCE(ent_balance, ce.balance), adjustment = COALESCE(ent_adjustment, ce.adjustment), entities = COALESCE(ent_entities, ce.entities) - WHERE ce.id = ent_id; + WHERE ce.id = ent_id + AND ( + ce.balance IS DISTINCT FROM COALESCE(ent_balance, ce.balance) + OR ce.adjustment IS DISTINCT FROM COALESCE(ent_adjustment, ce.adjustment) + OR ce.entities IS DISTINCT FROM COALESCE(ent_entities, ce.entities) + ); IF FOUND THEN updates_json := jsonb_set( diff --git a/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts b/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts index 120bbcb122..c63aa8dc0d 100644 --- a/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts +++ b/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts @@ -1,16 +1,22 @@ import type { AppEnv } from "@autumn/shared"; +import type { Redis } from "ioredis"; import { logger } from "@/external/logtail/logtailUtils.js"; import { currentRegion } from "@/external/redis/initRedis.js"; +import { getCurrentRedisV2InstanceName } from "@/external/redis/resolveRedisV2.js"; import { JobName } from "@/queue/JobName.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; +import { markSyncDirty } from "./dirtyState/markSyncDirty.js"; +import { buildSyncDirtyKeys } from "./dirtyState/syncDirtyKeys.js"; import type { UsageWindowUpdate } from "../types/usageWindowUpdate.js"; +/** Signal marker TTL: an enqueue that silently fails re-signals after this. */ +const SIGNAL_TTL_SECONDS = 60; + interface CustomerBatchContext { customerId: string; orgId: string; env: AppEnv; region: string; - timestamp: number; cusEntIds: Set; rolloverIds: Set; entityId?: string; @@ -18,7 +24,14 @@ interface CustomerBatchContext { // Counter SNAPSHOTS keyed by capped feature: each deduction returns the // complete post-deduction array, so merging across batched items is // last-write-wins (unlike cusEnt/rollover ids, which accumulate). - usageWindowUpdatesByFeatureId: Record; + usageWindowUpdatesByFeatureId: Record< + string, + { ts: number; update: UsageWindowUpdate } + >; + /** Coalescing flushes into Redis dirty state instead of enqueueing a full payload. */ + coalesce: boolean; + coalesceRedis?: Redis; + redisInstance?: string; } interface CustomerBatch { @@ -85,6 +98,8 @@ export class SyncBatchingManagerV3 { entityId, modifiedCusEntIdsByFeatureId, usageWindowUpdates, + coalesce, + coalesceRedis, }: { customerId: string; orgId: string; @@ -95,6 +110,10 @@ export class SyncBatchingManagerV3 { entityId?: string; modifiedCusEntIdsByFeatureId: Record; usageWindowUpdates?: UsageWindowUpdate[]; + /** Route this batch through the globally gated dirty state. */ + coalesce?: boolean; + /** The Redis instance the deduction ran on (dirty state must co-locate) */ + coalesceRedis?: Redis; }): void { const batchKey = this.buildBatchKey({ orgId, env, customerId }); let batch = this.customerBatches.get(batchKey); @@ -110,6 +129,13 @@ export class SyncBatchingManagerV3 { if (region) batch.context.region = region; if (entityId) batch.context.entityId = entityId; + if (coalesce && coalesceRedis) { + batch.context.coalesce = true; + batch.context.coalesceRedis = coalesceRedis; + batch.context.redisInstance = getCurrentRedisV2InstanceName({ + customerId, + }); + } for (const [featureId, ids] of Object.entries( modifiedCusEntIdsByFeatureId, @@ -121,9 +147,19 @@ export class SyncBatchingManagerV3 { } for (const usageWindowUpdate of usageWindowUpdates ?? []) { - batch.context.usageWindowUpdatesByFeatureId[ - usageWindowUpdate.feature_id - ] = usageWindowUpdate; + const timestampedUpdate = { + ts: Date.now(), + update: usageWindowUpdate, + }; + const existing = + batch.context.usageWindowUpdatesByFeatureId[ + usageWindowUpdate.feature_id + ]; + if (!existing || timestampedUpdate.ts >= existing.ts) { + batch.context.usageWindowUpdatesByFeatureId[ + usageWindowUpdate.feature_id + ] = timestampedUpdate; + } } const totalSize = @@ -187,11 +223,11 @@ export class SyncBatchingManagerV3 { orgId, env, region: region || currentRegion, - timestamp: Date.now(), cusEntIds: new Set(), rolloverIds: new Set(), modifiedCusEntIdsByFeatureId: {}, usageWindowUpdatesByFeatureId: {}, + coalesce: false, }, timer: null, }; @@ -293,16 +329,80 @@ export class SyncBatchingManagerV3 { return Bun.hash(dedupKey).toString(); } + /** Merge into Redis dirty state and signal on the empty->dirty transition. + * An enqueue failure clears the marker before the legacy fallback. */ + private async queueCoalescedSync({ + context, + }: { + context: CustomerBatchContext; + }): Promise { + const scope = { + orgId: context.orgId, + env: context.env, + customerId: context.customerId, + }; + + const { shouldSignal } = await markSyncDirty({ + redis: context.coalesceRedis!, + scope, + cusEntIds: Array.from(context.cusEntIds), + rolloverIds: Array.from(context.rolloverIds), + modifiedCusEntIdsByFeatureId: context.modifiedCusEntIdsByFeatureId, + usageWindowUpdates: Object.values(context.usageWindowUpdatesByFeatureId), + entityId: context.entityId, + signalTtlSeconds: SIGNAL_TTL_SECONDS, + }); + + if (!shouldSignal) return; + + // No DelaySeconds: FIFO queues reject per-message delay. The coalescing + // window is enforced by the drain handler, which waits out the window + // from `timestamp` before claiming. + try { + await addTaskToQueue({ + jobName: JobName.SyncCustomerDirty, + payload: { + customerId: context.customerId, + orgId: context.orgId, + env: context.env, + region: context.region, + redisInstance: context.redisInstance!, + timestamp: Date.now(), + }, + // Serializes drains per customer: two workers can never race on the + // same claim. + messageGroupId: `sync-dirty:${Bun.hash( + `${context.orgId}:${context.env}:${context.customerId}`, + )}`, + }); + } catch (error) { + const { signalKey } = buildSyncDirtyKeys(scope); + await context.coalesceRedis!.del(signalKey); + throw error; + } + } + private async queueSyncJob({ context, }: { context: CustomerBatchContext; }): Promise { + if (context.coalesce && context.coalesceRedis) { + try { + await this.queueCoalescedSync({ context }); + return; + } catch (error) { + logger.error( + `[SyncDirty] Failed to mark/signal for ${context.customerId}: ${error}`, + ); + } + } + const cusEntIds = Array.from(context.cusEntIds).sort(); const rolloverIds = Array.from(context.rolloverIds).sort(); const usageWindowUpdates = Object.values( context.usageWindowUpdatesByFeatureId, - ); + ).map(({ update }) => update); const messageDeduplicationId = this.buildDeduplicationId({ context, cusEntIds, diff --git a/server/src/internal/balances/utils/sync/dirtyState/claimSyncDirty.ts b/server/src/internal/balances/utils/sync/dirtyState/claimSyncDirty.ts new file mode 100644 index 0000000000..6d33731e1d --- /dev/null +++ b/server/src/internal/balances/utils/sync/dirtyState/claimSyncDirty.ts @@ -0,0 +1,119 @@ +import { randomUUID } from "node:crypto"; +import CLAIM_SCRIPT from "@/_luaScriptsV2/syncDirty/claimSyncDirty.lua"; +import CLEAR_CLAIM_SCRIPT from "@/_luaScriptsV2/syncDirty/clearSyncClaim.lua"; +import type { Redis } from "ioredis"; +import type { UsageWindowUpdate } from "../../types/usageWindowUpdate.js"; +import { DIRTY_FIELD_PREFIXES } from "./markSyncDirty.js"; +import { buildSyncDirtyKeys, type SyncDirtyScope } from "./syncDirtyKeys.js"; + +/** Claim survives this long if its worker dies mid-flush; the next claim + * (message redelivery) merges it back in. */ +const CLAIM_TTL_SECONDS = 24 * 60 * 60; + +export interface ClaimedSyncState { + cusEntIds: string[]; + rolloverIds: string[]; + modifiedCusEntIdsByFeatureId: Record; + usageWindowUpdates: UsageWindowUpdate[]; + entityId?: string; + generation: string; +} + +// A leftover claim merges markers and keeps the newest uw:* timestamp. The new +// generation takes ownership, and clearing the signal lets later writes re-signal. + +const decodeClaimFields = (flat: string[]): ClaimedSyncState => { + const state: ClaimedSyncState = { + cusEntIds: [], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: {}, + usageWindowUpdates: [], + generation: "", + }; + + for (let i = 0; i < flat.length; i += 2) { + const field = flat[i]; + const value = flat[i + 1]; + + if (field.startsWith(DIRTY_FIELD_PREFIXES.cusEnt)) { + state.cusEntIds.push(field.slice(DIRTY_FIELD_PREFIXES.cusEnt.length)); + } else if (field.startsWith(DIRTY_FIELD_PREFIXES.rollover)) { + state.rolloverIds.push(field.slice(DIRTY_FIELD_PREFIXES.rollover.length)); + } else if (field.startsWith(DIRTY_FIELD_PREFIXES.featureCusEnt)) { + const featureAndCusEnt = field.slice( + DIRTY_FIELD_PREFIXES.featureCusEnt.length, + ); + const separatorIndex = featureAndCusEnt.indexOf(":"); + if (separatorIndex === -1) continue; + const featureId = decodeURIComponent( + featureAndCusEnt.slice(0, separatorIndex), + ); + const cusEntId = decodeURIComponent( + featureAndCusEnt.slice(separatorIndex + 1), + ); + if (!state.modifiedCusEntIdsByFeatureId[featureId]) { + state.modifiedCusEntIdsByFeatureId[featureId] = []; + } + state.modifiedCusEntIdsByFeatureId[featureId].push(cusEntId); + } else if (field.startsWith(DIRTY_FIELD_PREFIXES.usageWindow)) { + state.usageWindowUpdates.push( + ( + JSON.parse(value) as { + ts: number; + snapshot: UsageWindowUpdate; + } + ).snapshot, + ); + } else if (field === DIRTY_FIELD_PREFIXES.entityId) { + state.entityId = value; + } else if (field === "__gen") { + state.generation = value; + } + } + + return state; +}; + +/** + * Atomically claims the customer's dirty state for a drain. Returns null when + * there is nothing to sync (already drained, or Redis lost the state — either + * way the signal is a safe no-op). The claim key persists until clearSyncClaim + * so a worker crash before the Postgres flush is recoverable on redelivery. + */ +export const claimSyncDirty = async ({ + redis, + scope, +}: { + redis: Redis; + scope: SyncDirtyScope; +}): Promise => { + const { dirtyKey, claimKey, signalKey } = buildSyncDirtyKeys(scope); + + const result = (await redis.eval( + CLAIM_SCRIPT, + 3, + dirtyKey, + claimKey, + signalKey, + CLAIM_TTL_SECONDS.toString(), + randomUUID(), + )) as string[] | null; + + if (!result || result.length === 0) return null; + return decodeClaimFields(result); +}; + + +/** Deletes the owned claim after a successful Postgres flush. */ +export const clearSyncClaim = async ({ + redis, + scope, + generation, +}: { + redis: Redis; + scope: SyncDirtyScope; + generation: string; +}): Promise => { + const { claimKey } = buildSyncDirtyKeys(scope); + await redis.eval(CLEAR_CLAIM_SCRIPT, 1, claimKey, generation); +}; diff --git a/server/src/internal/balances/utils/sync/dirtyState/markSyncDirty.ts b/server/src/internal/balances/utils/sync/dirtyState/markSyncDirty.ts new file mode 100644 index 0000000000..71272027f9 --- /dev/null +++ b/server/src/internal/balances/utils/sync/dirtyState/markSyncDirty.ts @@ -0,0 +1,93 @@ +import type { Redis } from "ioredis"; +import type { UsageWindowUpdate } from "../../types/usageWindowUpdate.js"; +import { buildSyncDirtyKeys, type SyncDirtyScope } from "./syncDirtyKeys.js"; + +/** Leak backstop: dirty state a worker never drains expires on its own. + * Reconciliation (sync conflicts / cache rebuild) covers anything expired. */ +const DIRTY_STATE_TTL_SECONDS = 24 * 60 * 60; + +/** + * Hash-field encoding of the dirty state (single hash per customer so the + * claim can RENAME it atomically): + * ce:{cusEntId} = "1" (selector set) + * ro:{rolloverId} = "1" (selector set) + * fc:{featureId}:{cusEntId} = "1" (feature -> cusEntIds map) + * uw:{featureId} = JSON ({ts, snapshot}, newest wins) + * entityId = string (last write wins) + */ +export const DIRTY_FIELD_PREFIXES = { + cusEnt: "ce:", + rollover: "ro:", + featureCusEnt: "fc:", + usageWindow: "uw:", + entityId: "entityId", +} as const; + +// Merge fields into the dirty hash, refresh its TTL, then attempt to set the +// signal marker (NX + TTL). shouldSignal is true when the marker was newly +// set — i.e. no signal is believed to be in flight. A single Lua eval keeps +// merge+decision atomic against concurrent marks and claims. +import MARK_SCRIPT from "@/_luaScriptsV2/syncDirty/markSyncDirty.lua"; + +export const markSyncDirty = async ({ + redis, + scope, + cusEntIds, + rolloverIds, + modifiedCusEntIdsByFeatureId, + usageWindowUpdates, + entityId, + signalTtlSeconds, +}: { + redis: Redis; + scope: SyncDirtyScope; + cusEntIds: string[]; + rolloverIds: string[]; + modifiedCusEntIdsByFeatureId: Record; + usageWindowUpdates: Array<{ ts: number; update: UsageWindowUpdate }>; + entityId?: string; + signalTtlSeconds: number; +}): Promise<{ shouldSignal: boolean }> => { + const { dirtyKey, signalKey } = buildSyncDirtyKeys(scope); + + const fieldArgs: string[] = []; + for (const cusEntId of cusEntIds) { + fieldArgs.push(`${DIRTY_FIELD_PREFIXES.cusEnt}${cusEntId}`, "1"); + } + for (const rolloverId of rolloverIds) { + fieldArgs.push(`${DIRTY_FIELD_PREFIXES.rollover}${rolloverId}`, "1"); + } + for (const [featureId, featureCusEntIds] of Object.entries( + modifiedCusEntIdsByFeatureId, + )) { + for (const cusEntId of featureCusEntIds) { + fieldArgs.push( + `${DIRTY_FIELD_PREFIXES.featureCusEnt}${encodeURIComponent(featureId)}:${encodeURIComponent(cusEntId)}`, + "1", + ); + } + } + for (const { ts, update } of usageWindowUpdates) { + fieldArgs.push( + `${DIRTY_FIELD_PREFIXES.usageWindow}${encodeURIComponent(update.feature_id)}`, + JSON.stringify({ ts, snapshot: update }), + ); + } + if (entityId) { + fieldArgs.push(DIRTY_FIELD_PREFIXES.entityId, entityId); + } + + if (fieldArgs.length === 0) return { shouldSignal: false }; + + const result = (await redis.eval( + MARK_SCRIPT, + 2, + dirtyKey, + signalKey, + signalTtlSeconds.toString(), + DIRTY_STATE_TTL_SECONDS.toString(), + ...fieldArgs, + )) as number; + + return { shouldSignal: result === 1 }; +}; diff --git a/server/src/internal/balances/utils/sync/dirtyState/syncDirtyKeys.ts b/server/src/internal/balances/utils/sync/dirtyState/syncDirtyKeys.ts new file mode 100644 index 0000000000..9580c77101 --- /dev/null +++ b/server/src/internal/balances/utils/sync/dirtyState/syncDirtyKeys.ts @@ -0,0 +1,29 @@ +export interface SyncDirtyScope { + orgId: string; + env: string; + customerId: string; +} + +/** + * Per-customer coalescing keys for balance-sync signals. All three MUST live + * on the same Redis instance as the customer's balance hashes: the mark is + * issued right after the deduction Lua, and claim uses RENAME (same-instance + * only). NOTE: not cluster-slot-safe (no hash tags) — these instances are + * dedicated/regional, not clustered; revisit if that ever changes. + */ +export const buildSyncDirtyKeys = ({ + orgId, + env, + customerId, +}: SyncDirtyScope): { + dirtyKey: string; + claimKey: string; + signalKey: string; +} => { + const scope = `${orgId}:${env}:${customerId}`; + return { + dirtyKey: `sync:dirty:${scope}`, + claimKey: `sync:claim:${scope}`, + signalKey: `sync:signal:${scope}`, + }; +}; diff --git a/server/src/internal/balances/utils/sync/syncItemV5.ts b/server/src/internal/balances/utils/sync/syncItemV5.ts new file mode 100644 index 0000000000..11c308a28c --- /dev/null +++ b/server/src/internal/balances/utils/sync/syncItemV5.ts @@ -0,0 +1,88 @@ +import type { AppEnv } from "@autumn/shared"; +import { getRedisV2ByInstanceName } from "@/external/redis/resolveRedisV2.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { claimSyncDirty, clearSyncClaim } from "./dirtyState/claimSyncDirty.js"; +import { syncItemV4 } from "./syncItemV4.js"; + +/** How long a signal waits before draining, so concurrent writes coalesce. */ +const COALESCE_WINDOW_MS = + process.env.NODE_ENV === "dev" || + process.env.NODE_ENV === "test" || + process.env.NODE_ENV === "development" + ? 500 + : 3_000; + +export interface SyncCustomerDirtyPayload { + customerId: string; + orgId: string; + env: AppEnv; + region?: string; + redisInstance?: string; + timestamp: number; +} + +/** + * Drains a customer's coalesced sync state (SyncCustomerDirty signal). + * + * Claims the dirty state (atomic move to a claim key), flushes the latest + * Redis balances for the claimed selectors via the V4 flush, then clears the + * claim. Ordering guarantees: + * - writes landing after the claim recreate the dirty key and re-signal — + * nothing written after our read is lost; + * - a crash before clearSyncClaim leaves the claim for the next delivery + * (message stays in SQS: transient errors rethrow and are retryable); + * - an empty claim (already drained / Redis loss) is a safe no-op. + */ +export const syncItemV5 = async ({ + ctx, + payload, +}: { + ctx: AutumnContext; + payload: SyncCustomerDirtyPayload; +}): Promise => { + const scope = { + orgId: payload.orgId, + env: payload.env, + customerId: payload.customerId, + }; + const redis = payload.redisInstance + ? (getRedisV2ByInstanceName(payload.redisInstance) ?? ctx.redisV2) + : ctx.redisV2; + + // Coalescing window: FIFO queues don't support per-message DelaySeconds, + // so the window is enforced here — writes landing between the signal and + // this claim merge into the same drain. Must stay well under the worker's + // 25s message timeout. + const windowRemainingMs = payload.timestamp + COALESCE_WINDOW_MS - Date.now(); + if (windowRemainingMs > 0) { + await new Promise((resolve) => + setTimeout(resolve, Math.min(windowRemainingMs, COALESCE_WINDOW_MS)), + ); + } + + const claimed = await claimSyncDirty({ redis, scope }); + if (!claimed) { + ctx.logger.debug( + `[SYNC V5] (${payload.customerId}) Nothing to drain (empty dirty state)`, + ); + return; + } + + await syncItemV4({ + ctx, + payload: { + customerId: payload.customerId, + entityId: claimed.entityId, + orgId: payload.orgId, + env: payload.env, + timestamp: payload.timestamp, + rolloverIds: claimed.rolloverIds, + modifiedCusEntIdsByFeatureId: claimed.modifiedCusEntIdsByFeatureId, + usageWindowUpdates: claimed.usageWindowUpdates, + }, + }); + + // Only after a successful flush (syncItemV4 handles conflicts internally + // by invalidating the cache — that counts as handled). + await clearSyncClaim({ redis, scope, generation: claimed.generation }); +}; diff --git a/server/src/internal/billing/v2/actions/attach/errors/handleLicenseErrors/handleDroppedLicenseErrors.ts b/server/src/internal/billing/v2/actions/attach/errors/handleLicenseErrors/handleDroppedLicenseErrors.ts index 18a2c4859a..d25e5db487 100644 --- a/server/src/internal/billing/v2/actions/attach/errors/handleLicenseErrors/handleDroppedLicenseErrors.ts +++ b/server/src/internal/billing/v2/actions/attach/errors/handleLicenseErrors/handleDroppedLicenseErrors.ts @@ -11,9 +11,7 @@ const licensePlanIdOf = (customerLicense: FullCustomerLicense) => customerLicense.planLicense?.product.id ?? customerLicense.license_internal_product_id; -/** Blocks an immediate switch that would strand active assignments: a used - * outgoing pool must have a successor (same license plan, or a 1:1 group - * match) on the incoming plan. */ +/** Blocks ambiguous mappings; dropped pools inherit their parent's lifecycle. */ export const handleDroppedLicenseErrors = ({ billingContext, }: { @@ -28,20 +26,18 @@ export const handleDroppedLicenseErrors = ({ }); for (const { outgoingCustomerLicense, reason, group } of unmatched) { + if (reason === "dropped") continue; const used = customerLicenseToUsage({ customerLicense: outgoingCustomerLicense, }); if (used === 0) continue; const licensePlanId = licensePlanIdOf(outgoingCustomerLicense); - const conflict = - reason === "ambiguous" - ? `the licenses in group "${group}" are not a 1:1 match on the incoming plan` - : "the incoming plan drops the license"; throw new RecaseError({ message: `License changes conflict with active license assignments: ` + - `${used} assigned for ${licensePlanId}, but ${conflict}. Release licenses first.`, + `${used} assigned for ${licensePlanId}, but the licenses in group "${group}" ` + + "are not a 1:1 match on the incoming plan. Release licenses first.", code: ErrCode.InvalidRequest, statusCode: 400, }); diff --git a/server/src/internal/billing/v2/actions/createSchedule/compute/computeCreateSchedulePlan.ts b/server/src/internal/billing/v2/actions/createSchedule/compute/computeCreateSchedulePlan.ts index 60d5d83cb8..36de1d011d 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/compute/computeCreateSchedulePlan.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/compute/computeCreateSchedulePlan.ts @@ -5,6 +5,7 @@ import type { import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; import { finalizeLineItems } from "@/internal/billing/v2/compute/finalize/finalizeLineItems"; +import { computePooledBalanceTransitionPlan } from "@/internal/billing/v2/pooledBalances/compute/computePooledBalanceTransitionPlan"; import { cusProductsToOneOffPrepaidCarryOvers } from "@/internal/billing/v2/utils/handleOneOffPrepaidCarryOvers/cusProductToOneOffPrepaidCarryOvers"; import { billingContextToRecurringAndScheduled } from "../utils/billingContextToRecurringAndScheduled"; import { computeImmediatePhaseCustomerProducts } from "./computeImmediatePhaseCustomerProducts"; @@ -46,15 +47,24 @@ export const computeCreateSchedulePlan = ({ billingContext, existingScheduledCustomerProducts, }); + const { pooledBalancePlan } = computePooledBalanceTransitionPlan({ + ctx, + fullCustomer: billingContext.fullCustomer, + outgoingCustomerProducts: currentRecurringCustomerProducts, + incomingCustomerProducts: immediate.insertCustomerProducts, + stripeSubscriptionId: billingContext.stripeSubscription?.id, + now: billingContext.currentEpochMs, + }); + const immediateCustomerProducts = immediate.insertCustomerProducts; const allInsertCustomerProducts = [ - ...immediate.insertCustomerProducts, + ...immediateCustomerProducts, ...scheduled.insertCustomerProducts, ]; const { allLineItems, updateCustomerEntitlements } = buildAutumnLineItems({ ctx, - newCustomerProducts: immediate.insertCustomerProducts, + newCustomerProducts: immediateCustomerProducts, deletedCustomerProducts: currentRecurringCustomerProducts, billingContext, includeArrearLineItems: currentRecurringCustomerProducts.length > 0, @@ -80,6 +90,7 @@ export const computeCreateSchedulePlan = ({ lineItems: allLineItems, updateCustomerEntitlements, insertCustomerEntitlements: oneOffPrepaidCarryOvers.customerEntitlements, + pooledBalancePlan, }; autumnBillingPlan.lineItems = finalizeLineItems({ @@ -91,7 +102,7 @@ export const computeCreateSchedulePlan = ({ const immediatePhase: SchedulePhasePlan = { startsAt: billingContext.immediatePhase.starts_at, - customerProductIds: immediate.insertCustomerProducts.map( + customerProductIds: immediateCustomerProducts.map( (customerProduct) => customerProduct.id, ), }; diff --git a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts index bd7a83a241..bdff456610 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts @@ -7,6 +7,7 @@ import type { import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan"; import { billingPlanToAttachPreview } from "@/internal/billing/v2/utils/billingPlan/billingPlanToAttachPreview"; +import { computeAttachPreviewBillingPlan } from "@/internal/billing/v2/utils/billingPlan/preview/computeAttachPreviewBillingPlan"; import { computeCreateSchedulePlan } from "./compute/computeCreateSchedulePlan"; import { handleCreateScheduleBillingPlanErrors, @@ -54,13 +55,23 @@ export const previewCreateScheduleWithContext = async ({ handleCreateScheduleBillingPlanErrors({ ctx, billingContext, billingPlan }); + const previewBillingPlan = await computeAttachPreviewBillingPlan({ + ctx, + billingContext, + autumnBillingPlan, + }); + const billingPlanWithPreview = { + ...billingPlan, + preview: previewBillingPlan, + }; + return { billingContext, - billingPlan, + billingPlan: billingPlanWithPreview, preview: await billingPlanToAttachPreview({ ctx, billingContext, - billingPlan, + billingPlan: billingPlanWithPreview, }), }; }; diff --git a/server/src/internal/billing/v2/actions/index.ts b/server/src/internal/billing/v2/actions/index.ts index 982781bc8f..d8f1ee7589 100644 --- a/server/src/internal/billing/v2/actions/index.ts +++ b/server/src/internal/billing/v2/actions/index.ts @@ -12,6 +12,7 @@ import { multiUpdate } from "@/internal/billing/v2/actions/multiUpdate/multiUpda import { releaseLicense } from "@/internal/billing/v2/actions/releaseLicense/releaseLicense"; import { previewRestore } from "@/internal/billing/v2/actions/restore/previewRestore"; import { restore } from "@/internal/billing/v2/actions/restore/restore"; +import { rollback } from "@/internal/billing/v2/actions/rollback/rollback"; import { setupPayment } from "@/internal/billing/v2/actions/setupPayment/setupPayment"; import { sync } from "@/internal/billing/v2/actions/sync/sync"; import { syncProposals } from "@/internal/billing/v2/actions/sync/syncProposals"; @@ -30,6 +31,7 @@ export const billingActions = { setupPayment: setupPayment, updateSubscription: updateSubscription, releaseLicense: releaseLicense, + rollback: rollback, migrate: migrate, restore: restore, previewRestore: previewRestore, diff --git a/server/src/internal/billing/v2/actions/rollback/compute/computeRollbackOperations.ts b/server/src/internal/billing/v2/actions/rollback/compute/computeRollbackOperations.ts new file mode 100644 index 0000000000..9cef38896a --- /dev/null +++ b/server/src/internal/billing/v2/actions/rollback/compute/computeRollbackOperations.ts @@ -0,0 +1,104 @@ +import type { AutumnBillingPlan } from "@autumn/shared"; +import { getCustomerProductPlanOperations } from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; +import { operationsOrUndefined } from "@/internal/billing/v2/utils/billingPlan/operationsOrUndefined"; +import { + getReplaceableRestorations, + invertCustomerEntitlementUpdate, +} from "./invertCustomerEntitlementOperations"; +import { + invertCustomerProductPatch, + invertCustomerProductUpdate, +} from "./invertCustomerProductOperations"; + +type RollbackOperations = Pick< + AutumnBillingPlan, + | "insertCustomerProducts" + | "updateCustomerProducts" + | "deleteCustomerProducts" + | "patchCustomerProducts" + | "updateCustomerEntitlements" +>; + +export const computeRollbackOperations = ({ + autumnBillingPlan, +}: { + autumnBillingPlan: AutumnBillingPlan; +}): RollbackOperations => { + const { + inserts: originalInserts, + updates: originalUpdates, + deletes: originalDeletes, + patches: originalPatches, + } = getCustomerProductPlanOperations({ autumnBillingPlan }); + + const insertedProductIds = new Set(originalInserts.map(({ id }) => id)); + const deletedProductIds = new Set(originalDeletes.map(({ id }) => id)); + const patchesToReverse = originalPatches.filter( + ({ customerProduct }) => + !insertedProductIds.has(customerProduct.id) && + !deletedProductIds.has(customerProduct.id), + ); + const insertedEntitlementIds = new Set( + patchesToReverse.flatMap(({ insertCustomerEntitlements }) => + insertCustomerEntitlements.map(({ id }) => id), + ), + ); + const deletedEntitlementIds = new Set( + patchesToReverse.flatMap(({ deleteCustomerEntitlements }) => + deleteCustomerEntitlements.map(({ id }) => id), + ), + ); + + const customerProductsToDelete = originalInserts.filter( + ({ id }) => !deletedProductIds.has(id), + ); + const customerProductsToRestore = originalDeletes.filter( + ({ id }) => !insertedProductIds.has(id), + ); + const entitlementUpdatesToReverse = ( + autumnBillingPlan.updateCustomerEntitlements ?? [] + ).filter( + ({ customerEntitlement }) => + !insertedProductIds.has(customerEntitlement.customer_product_id ?? "") && + !deletedProductIds.has(customerEntitlement.customer_product_id ?? "") && + !insertedEntitlementIds.has(customerEntitlement.id) && + !deletedEntitlementIds.has(customerEntitlement.id), + ); + + const updateCustomerEntitlements = [ + ...entitlementUpdatesToReverse + .slice() + .reverse() + .map(invertCustomerEntitlementUpdate), + ...getReplaceableRestorations({ + customerProducts: customerProductsToRestore, + customerEntitlements: patchesToReverse.flatMap( + ({ deleteCustomerEntitlements }) => deleteCustomerEntitlements, + ), + }), + ]; + + return { + insertCustomerProducts: customerProductsToRestore.slice().reverse(), + updateCustomerProducts: operationsOrUndefined( + originalUpdates + .filter( + ({ customerProduct }) => + !insertedProductIds.has(customerProduct.id) && + !deletedProductIds.has(customerProduct.id), + ) + .slice() + .reverse() + .map(invertCustomerProductUpdate), + ), + deleteCustomerProducts: operationsOrUndefined( + customerProductsToDelete.slice().reverse(), + ), + patchCustomerProducts: operationsOrUndefined( + patchesToReverse.slice().reverse().map(invertCustomerProductPatch), + ), + updateCustomerEntitlements: operationsOrUndefined( + updateCustomerEntitlements, + ), + }; +}; diff --git a/server/src/internal/billing/v2/actions/rollback/compute/computeRollbackPlan.ts b/server/src/internal/billing/v2/actions/rollback/compute/computeRollbackPlan.ts new file mode 100644 index 0000000000..3dcefe7ac0 --- /dev/null +++ b/server/src/internal/billing/v2/actions/rollback/compute/computeRollbackPlan.ts @@ -0,0 +1,16 @@ +import type { AutumnBillingPlan } from "@autumn/shared"; +import { handleRollbackPlanErrors } from "../errors/handleRollbackPlanErrors"; +import { computeRollbackOperations } from "./computeRollbackOperations"; + +export const computeRollbackPlan = ({ + autumnBillingPlan, +}: { + autumnBillingPlan: AutumnBillingPlan; +}): AutumnBillingPlan => { + handleRollbackPlanErrors({ autumnBillingPlan }); + + return { + customerId: autumnBillingPlan.customerId, + ...computeRollbackOperations({ autumnBillingPlan }), + }; +}; diff --git a/server/src/internal/billing/v2/actions/rollback/compute/invertCustomerEntitlementOperations.ts b/server/src/internal/billing/v2/actions/rollback/compute/invertCustomerEntitlementOperations.ts new file mode 100644 index 0000000000..9af15d9cab --- /dev/null +++ b/server/src/internal/billing/v2/actions/rollback/compute/invertCustomerEntitlementOperations.ts @@ -0,0 +1,48 @@ +import type { + AutumnBillingPlan, + UpdateCustomerEntitlement, +} from "@autumn/shared"; +import { getPreviousValues } from "@/internal/billing/v2/utils/billingPlan/getPreviousValues"; + +export const invertCustomerEntitlementUpdate = ( + update: UpdateCustomerEntitlement, +): UpdateCustomerEntitlement => + update.updates + ? { + customerEntitlement: update.customerEntitlement, + updates: getPreviousValues({ + before: update.customerEntitlement, + updates: update.updates, + }), + } + : { + customerEntitlement: update.customerEntitlement, + balanceChange: update.balanceChange ? -update.balanceChange : undefined, + insertReplaceables: update.deletedReplaceables, + deletedReplaceables: update.insertReplaceables?.map((replaceable) => ({ + ...replaceable, + from_entity_id: replaceable.from_entity_id ?? null, + delete_next_cycle: replaceable.delete_next_cycle ?? false, + })), + }; + +export const getReplaceableRestorations = ({ + customerProducts, + customerEntitlements, +}: { + customerProducts: NonNullable; + customerEntitlements: NonNullable< + AutumnBillingPlan["patchCustomerProducts"] + >[number]["deleteCustomerEntitlements"]; +}): UpdateCustomerEntitlement[] => + [ + ...customerProducts.flatMap( + ({ customer_entitlements }) => customer_entitlements, + ), + ...customerEntitlements, + ] + .filter(({ replaceables }) => replaceables.length > 0) + .map((customerEntitlement) => ({ + customerEntitlement, + insertReplaceables: customerEntitlement.replaceables, + })); diff --git a/server/src/internal/billing/v2/actions/rollback/compute/invertCustomerProductOperations.ts b/server/src/internal/billing/v2/actions/rollback/compute/invertCustomerProductOperations.ts new file mode 100644 index 0000000000..490d4c0181 --- /dev/null +++ b/server/src/internal/billing/v2/actions/rollback/compute/invertCustomerProductOperations.ts @@ -0,0 +1,27 @@ +import type { AutumnBillingPlan, CustomerProductUpdate } from "@autumn/shared"; +import { + applyCustomerProductPatch, + applyCustomerProductUpdate, +} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; +import { getPreviousValues } from "@/internal/billing/v2/utils/billingPlan/getPreviousValues"; + +export const invertCustomerProductUpdate = ({ + customerProduct, + updates, +}: CustomerProductUpdate): CustomerProductUpdate => ({ + customerProduct: applyCustomerProductUpdate({ customerProduct, updates }), + updates: getPreviousValues({ before: customerProduct, updates }), +}); + +export const invertCustomerProductPatch = ( + patch: NonNullable[number], +) => ({ + customerProduct: applyCustomerProductPatch({ + customerProduct: patch.customerProduct, + patch, + }), + insertCustomerEntitlements: patch.deleteCustomerEntitlements, + insertCustomerPrices: patch.deleteCustomerPrices, + deleteCustomerEntitlements: patch.insertCustomerEntitlements, + deleteCustomerPrices: patch.insertCustomerPrices, +}); diff --git a/server/src/internal/billing/v2/actions/rollback/errors/handleRollbackPlanErrors.ts b/server/src/internal/billing/v2/actions/rollback/errors/handleRollbackPlanErrors.ts new file mode 100644 index 0000000000..436ca43eb4 --- /dev/null +++ b/server/src/internal/billing/v2/actions/rollback/errors/handleRollbackPlanErrors.ts @@ -0,0 +1,98 @@ +import { type AutumnBillingPlan, ErrCode, RecaseError } from "@autumn/shared"; +import { getCustomerProductPlanOperations } from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; + +const ROLLBACK_OPERATION_FIELDS = [ + "insertCustomerProducts", + "updateCustomerProduct", + "updateCustomerProducts", + "deleteCustomerProduct", + "deleteCustomerProducts", + "patchCustomerProducts", + "updateCustomerEntitlements", +] satisfies (keyof AutumnBillingPlan)[]; + +const ROLLBACK_INERT_FIELDS = [ + "customerId", + "updateByStripeScheduleId", + "lineItems", + "customLineItems", + "refundPlan", +] satisfies (keyof AutumnBillingPlan)[]; + +const ROLLBACK_FIELDS = new Set([ + ...ROLLBACK_OPERATION_FIELDS, + ...ROLLBACK_INERT_FIELDS, +]); + +const throwRollbackError = (message: string): never => { + throw new RecaseError({ + message: `billing.rollback: ${message}`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); +}; + +const isPopulated = (value: unknown) => + value !== undefined && (!Array.isArray(value) || value.length > 0); + +const validateUniqueIds = ({ + ids, + operation, +}: { + ids: string[]; + operation: string; +}) => { + const seen = new Set(); + for (const id of ids) { + if (seen.has(id)) { + throwRollbackError(`duplicate ${operation}: ${id}`); + } + seen.add(id); + } +}; + +export const handleRollbackPlanErrors = ({ + autumnBillingPlan, +}: { + autumnBillingPlan: AutumnBillingPlan; +}) => { + const unsupportedFields = Object.entries(autumnBillingPlan) + .filter( + ([key, value]) => + !ROLLBACK_FIELDS.has(key as keyof AutumnBillingPlan) && + isPopulated(value), + ) + .map(([key]) => key); + if (unsupportedFields.length > 0) { + throwRollbackError( + `unsupported operations: ${unsupportedFields.join(", ")}`, + ); + } + + const { deletes, patches } = getCustomerProductPlanOperations({ + autumnBillingPlan, + }); + validateUniqueIds({ + ids: deletes.map(({ id }) => id), + operation: "customer product delete", + }); + validateUniqueIds({ + ids: patches.flatMap(({ deleteCustomerEntitlements }) => + deleteCustomerEntitlements.map(({ id }) => id), + ), + operation: "customer entitlement delete", + }); + validateUniqueIds({ + ids: patches.flatMap(({ deleteCustomerPrices }) => + deleteCustomerPrices.map(({ id }) => id), + ), + operation: "customer price delete", + }); + validateUniqueIds({ + ids: (autumnBillingPlan.updateCustomerEntitlements ?? []).flatMap( + ({ deletedReplaceables }) => + (deletedReplaceables ?? []).map(({ id }) => id), + ), + operation: "replaceable delete", + }); +}; diff --git a/server/src/internal/billing/v2/actions/rollback/rollback.ts b/server/src/internal/billing/v2/actions/rollback/rollback.ts new file mode 100644 index 0000000000..112887243d --- /dev/null +++ b/server/src/internal/billing/v2/actions/rollback/rollback.ts @@ -0,0 +1,22 @@ +import type { AutumnBillingPlan } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer"; +import { computeRollbackPlan } from "./compute/computeRollbackPlan"; + +export const rollback = async ({ + ctx, + autumnBillingPlan, +}: { + ctx: AutumnContext; + autumnBillingPlan: AutumnBillingPlan; +}) => { + const rollbackPlan = computeRollbackPlan({ autumnBillingPlan }); + await executeAutumnBillingPlan({ ctx, autumnBillingPlan: rollbackPlan }); + await deleteCachedFullCustomer({ + ctx, + customerId: autumnBillingPlan.customerId, + source: "billing.rollback", + }); + return rollbackPlan; +}; diff --git a/server/src/internal/billing/v2/actions/sync/compute/computeSyncPlan.ts b/server/src/internal/billing/v2/actions/sync/compute/computeSyncPlan.ts index c881d4ec3c..d9af4e51a5 100644 --- a/server/src/internal/billing/v2/actions/sync/compute/computeSyncPlan.ts +++ b/server/src/internal/billing/v2/actions/sync/compute/computeSyncPlan.ts @@ -1,5 +1,11 @@ -import type { AutumnBillingPlan, SyncBillingContext } from "@autumn/shared"; +import { + type AutumnBillingPlan, + CusProductStatus, + type FullCusProduct, + type SyncBillingContext, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { computePooledBalanceTransitionPlan } from "@/internal/billing/v2/pooledBalances/compute/computePooledBalanceTransitionPlan"; import { initSubscriptionFromStripe } from "@/internal/subscriptions/utils/initSubscriptionFromStripe"; import { syncContextToCurrencyLock } from "../utils/syncContextUtils"; import { @@ -37,6 +43,23 @@ export const computeSyncPlan = ({ }): ComputedSyncPlan => { const immediate = computeSyncImmediatePhase({ ctx, syncContext }); const future = computeSyncFuturePhases({ ctx, syncContext }); + const outgoingCustomerProducts: FullCusProduct[] = []; + for (const { customerProduct, updates } of [ + ...immediate.updateCustomerProducts, + ...future.updateCustomerProducts, + ]) { + if (updates.status === CusProductStatus.Expired) { + outgoingCustomerProducts.push(customerProduct); + } + } + const { pooledBalancePlan } = computePooledBalanceTransitionPlan({ + ctx, + fullCustomer: syncContext.fullCustomer, + outgoingCustomerProducts, + incomingCustomerProducts: immediate.insertCustomerProducts, + now: syncContext.currentEpochMs, + }); + const preparedImmediateCustomerProducts = immediate.insertCustomerProducts; const upsertSubscription = syncContext.stripeSubscription ? initSubscriptionFromStripe({ @@ -49,7 +72,7 @@ export const computeSyncPlan = ({ customerId: syncContext.fullCustomer.id ?? syncContext.fullCustomer.internal_id, insertCustomerProducts: [ - ...immediate.insertCustomerProducts, + ...preparedImmediateCustomerProducts, ...future.insertCustomerProducts, ], updateCustomerProducts: @@ -75,6 +98,7 @@ export const computeSyncPlan = ({ : undefined, lockCustomerCurrency: syncContextToCurrencyLock({ syncContext }), upsertSubscription, + pooledBalancePlan, }; // Single-phase sync (no schedule) → don't materialize any Autumn schedule. @@ -87,7 +111,7 @@ export const computeSyncPlan = ({ ? { startsAt: syncContext.immediatePhase.startsAt, endsAt: syncContext.immediatePhase.endsAt, - customerProductIds: immediate.insertCustomerProducts.map( + customerProductIds: preparedImmediateCustomerProducts.map( (cp) => cp.id, ), } diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts index ef554ebd97..0148bcb278 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts @@ -94,7 +94,6 @@ export const computeUpdateSubscriptionPlan = async ({ ctx, plan, billingContext, - params, }); // When skipBillingChanges is true, Stripe is never called, so the post-Stripe diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts index 8c7e299f2c..a7096fdc6c 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts @@ -2,12 +2,12 @@ import { type AutumnBillingPlan, isCustomerProductOneOff, type UpdateSubscriptionBillingContext, - type UpdateSubscriptionV1Params, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { computeRefundPlan } from "@/internal/billing/v2/compute/finalize/computeRefundPlan"; import { finalizeLineItems } from "@/internal/billing/v2/compute/finalize/finalizeLineItems"; import { customerProductToBillingCycleAnchor } from "@/internal/billing/v2/utils/initFullCustomerProduct/cycleAnchorUtils"; +import { finalizeUpdateSubscriptionPooledBalancePlan } from "./finalizeUpdateSubscriptionPooledBalancePlan"; const applyAnchorResetCustomerProductUpdate = ({ plan, @@ -42,12 +42,10 @@ export const finalizeUpdateSubscriptionPlan = async ({ ctx, plan, billingContext, - params, }: { ctx: AutumnContext; plan: AutumnBillingPlan; billingContext: UpdateSubscriptionBillingContext; - params: UpdateSubscriptionV1Params; }): Promise => { plan = applyAnchorResetCustomerProductUpdate({ plan, billingContext }); @@ -73,6 +71,11 @@ export const finalizeUpdateSubscriptionPlan = async ({ plan.lineItems = filteredLineItems; plan.refundPlan = refundPlan; + plan = finalizeUpdateSubscriptionPooledBalancePlan({ + ctx, + plan, + billingContext, + }); return plan; }; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPooledBalancePlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPooledBalancePlan.ts new file mode 100644 index 0000000000..710badb514 --- /dev/null +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPooledBalancePlan.ts @@ -0,0 +1,56 @@ +import { + type AutumnBillingPlan, + CusProductStatus, + type UpdateSubscriptionBillingContext, + UpdateSubscriptionIntent, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { computePooledBalanceTransitionPlan } from "@/internal/billing/v2/pooledBalances/compute/computePooledBalanceTransitionPlan"; +import { + applyCustomerProductPatch, + getPatchCustomerProducts, +} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; + +export const finalizeUpdateSubscriptionPooledBalancePlan = ({ + ctx, + plan, + billingContext, +}: { + ctx: AutumnContext; + plan: AutumnBillingPlan; + billingContext: UpdateSubscriptionBillingContext; +}): AutumnBillingPlan => { + const transitionsImmediately = + billingContext.cancelAction === "cancel_immediately" || + (billingContext.intent === UpdateSubscriptionIntent.UpdatePlan && + billingContext.customerProduct.status !== CusProductStatus.Scheduled); + if (!transitionsImmediately) return plan; + + const updatesExistingCustomerProduct = + billingContext.patchContext?.mode === "existing"; + const incomingCustomerProductSnapshots = updatesExistingCustomerProduct + ? getPatchCustomerProducts({ autumnBillingPlan: plan }).map((patch) => + applyCustomerProductPatch({ + customerProduct: patch.customerProduct, + patch, + }), + ) + : plan.insertCustomerProducts; + const { pooledBalancePlan } = computePooledBalanceTransitionPlan({ + ctx, + fullCustomer: billingContext.fullCustomer, + outgoingCustomerProducts: [billingContext.customerProduct], + incomingCustomerProducts: incomingCustomerProductSnapshots, + stripeSubscriptionId: billingContext.stripeSubscription?.id, + now: billingContext.currentEpochMs, + }); + + if (updatesExistingCustomerProduct) { + return { ...plan, pooledBalancePlan }; + } + + return { + ...plan, + pooledBalancePlan, + }; +}; diff --git a/server/src/internal/billing/v2/compute/customerLicenseTransitions/applyCustomerLicenseTransitions.ts b/server/src/internal/billing/v2/compute/customerLicenseTransitions/applyCustomerLicenseTransitions.ts index 466bd8206f..bb1d19b0ea 100644 --- a/server/src/internal/billing/v2/compute/customerLicenseTransitions/applyCustomerLicenseTransitions.ts +++ b/server/src/internal/billing/v2/compute/customerLicenseTransitions/applyCustomerLicenseTransitions.ts @@ -51,17 +51,22 @@ export const applyCustomerLicenseTransitions = ({ for (const customerLicenseTransition of customerLicenseTransitions) { const planLicenseId = customerLicenseTransition.incomingCustomerLicense.planLicense?.id; - if (planLicenseId) { - customerLicenseBillingContext.projectedPlanLicenseIds.add(planLicenseId); + if ( + planLicenseId && + !customerLicenseBillingContext.projectedPlanLicenseIds.includes( + planLicenseId, + ) + ) { + customerLicenseBillingContext.projectedPlanLicenseIds.push(planLicenseId); } customerLicenseBillingContext.licenseBillingPriceRows.push( ...transitionLicenseBillingPriceRows({ licenseBillingPriceRows: persistedRows, customerLicenseTransition, assignedSeatCount: - customerLicenseBillingContext.assignedSeatCountByCustomerLicenseId.get( - customerLicenseTransition.outgoingCustomerLicense.id, - ) ?? 0, + customerLicenseBillingContext.assignedSeatCountByCustomerLicenseId[ + customerLicenseTransition.outgoingCustomerLicense.id + ] ?? 0, }), ); } diff --git a/server/src/internal/billing/v2/compute/finalize/finalizeLineItems.ts b/server/src/internal/billing/v2/compute/finalize/finalizeLineItems.ts index d7381f5e4b..99e33deed5 100644 --- a/server/src/internal/billing/v2/compute/finalize/finalizeLineItems.ts +++ b/server/src/internal/billing/v2/compute/finalize/finalizeLineItems.ts @@ -37,6 +37,7 @@ export const finalizeLineItems = ({ if ( billingContext.requestedProrationBehavior === "none" && + billingContext.stripeSubscription && !billingContext.anchorResetRefund?.noPartialRefund ) { return []; diff --git a/server/src/internal/billing/v2/execute/executeAutumnActions/executeCustomerLicenseTransitions.ts b/server/src/internal/billing/v2/execute/executeAutumnActions/executeCustomerLicenseTransitions.ts index 443bc5aef1..590d911b40 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnActions/executeCustomerLicenseTransitions.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnActions/executeCustomerLicenseTransitions.ts @@ -1,8 +1,10 @@ import type { CustomerLicenseTransition } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { batchTransition } from "@/internal/billing/v2/actions/batchTransition/batchTransition"; import { batchTransitionTask } from "@/internal/billing/v2/actions/batchTransition/tasks/batchTransitionTask"; import { isSameRowTransition } from "@/internal/billing/v2/compute/customerLicenseTransitions/isSameRowTransition"; import { customerLicenseRepo } from "@/internal/licenses/repos/customerLicenseRepo"; +import { shouldRunTriggerTasksInline } from "@/trigger/utils/shouldRunTriggerTasksInline"; import { generateId } from "@/utils/genUtils"; /** Converges license pools and their assigned seat definitions. @@ -51,16 +53,32 @@ export const executeCustomerLicenseTransitions = async ({ }); } + const executionScope = { + batchTransitionId: generateId("batch_transition"), + assignmentCutoffMs: Date.now(), + }; + + if (shouldRunTriggerTasksInline()) { + void batchTransition({ ctx, transition, executionScope }).catch( + (error) => { + ctx.logger.error("[licenseTransitions] batch transition failed", { + data: { + customerLicenseLinkId: updates.linkId, + error: error instanceof Error ? error.message : String(error), + }, + }); + }, + ); + continue; + } + await batchTransitionTask.trigger( { orgId: ctx.org.id, env: ctx.env, customerId: ctx.customerId, transition, - executionScope: { - batchTransitionId: generateId("batch_transition"), - assignmentCutoffMs: Date.now(), - }, + executionScope, }, { concurrencyKey: updates.linkId }, ); diff --git a/server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/applyIncomingPooledBalanceSources.ts b/server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/applyIncomingPooledBalanceSources.ts index 6503db681d..d05c231ebf 100644 --- a/server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/applyIncomingPooledBalanceSources.ts +++ b/server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/applyIncomingPooledBalanceSources.ts @@ -11,7 +11,6 @@ import { computePooledBalanceContributionAmounts } from "./computePooledBalanceC import { computePooledBalanceLifecycle } from "./computePooledBalanceLifecycle"; import { initCustomerEntitlementPooledIdentity } from "./initCustomerEntitlementPooledIdentity"; import { initPooledBalanceContribution } from "./initPooledBalanceContribution"; -import { normalizePooledBalanceContributionCustomerEntitlement } from "./normalizePooledBalanceContributionCustomerEntitlement"; import { upsertPooledBalance } from "./upsertPooledBalance"; export const applyIncomingPooledBalanceSources = ({ @@ -84,9 +83,5 @@ export const applyIncomingPooledBalanceSources = ({ pooledBalancePlan: computeContext.plan, contribution, }); - normalizePooledBalanceContributionCustomerEntitlement({ - contributionCustomerEntitlement, - contribution, - }); } }; diff --git a/server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/initCustomerEntitlementPooledIdentity.ts b/server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/initCustomerEntitlementPooledIdentity.ts index 0490707bb1..d44df45a71 100644 --- a/server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/initCustomerEntitlementPooledIdentity.ts +++ b/server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/initCustomerEntitlementPooledIdentity.ts @@ -2,6 +2,7 @@ import { EntInterval, type FullCustomerEntitlement, type PooledBalanceIdentity, + rolloverConfigToSignature, } from "@autumn/shared"; import type { PooledBalanceLifecycle } from "../types/pooledBalanceComputeTypes"; @@ -27,7 +28,7 @@ export const initCustomerEntitlementPooledIdentity = ({ resetMode: lifecycle.resetMode, stripeSubscriptionId: lifecycle.stripeSubscriptionId, customerLicenseLinkId: lifecycle.customerLicenseLinkId, - rolloverSignature: customerEntitlement.entitlement.rollover - ? JSON.stringify(customerEntitlement.entitlement.rollover) - : "none", + rolloverSignature: rolloverConfigToSignature({ + rollover: customerEntitlement.entitlement.rollover, + }), }); diff --git a/server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/initPooledBalanceGraph.ts b/server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/initPooledBalanceGraph.ts index 1d06a59afa..2cd559ca0a 100644 --- a/server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/initPooledBalanceGraph.ts +++ b/server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/initPooledBalanceGraph.ts @@ -1,7 +1,8 @@ -import type { - FullCusProduct, - FullCustomerEntitlement, - PooledBalanceIdentity, +import { + type FullCusProduct, + type FullCustomerEntitlement, + type PooledBalanceIdentity, + PooledBalanceResetMode, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { generateId } from "@/utils/genUtils"; @@ -57,6 +58,8 @@ export const initPooledBalanceGraph = ({ next_reset_at: nextResetAt, cache_version: 0, external_id: null, + reset_by_invoice: + identity.resetMode === PooledBalanceResetMode.Subscription, is_pooled_balance: true, pooled_balance_id: pooledBalanceId, pooled_contribution_id: null, diff --git a/server/src/internal/billing/v2/pooledBalances/compute/applyPooledBalancePlanToIncomingCustomerProducts.ts b/server/src/internal/billing/v2/pooledBalances/compute/applyPooledBalancePlanToIncomingCustomerProducts.ts new file mode 100644 index 0000000000..a6e03d9b70 --- /dev/null +++ b/server/src/internal/billing/v2/pooledBalances/compute/applyPooledBalancePlanToIncomingCustomerProducts.ts @@ -0,0 +1,43 @@ +import type { + FullCusProduct, + InsertPooledBalanceContribution, + PooledBalancePlan, +} from "@autumn/shared"; +import { normalizePooledBalanceContributionCustomerEntitlement } from "../normalizePooledBalanceContributionCustomerEntitlement"; + +export const applyPooledBalancePlanToIncomingCustomerProducts = ({ + customerProducts, + pooledBalancePlan, +}: { + customerProducts: FullCusProduct[]; + pooledBalancePlan?: PooledBalancePlan; +}) => { + if (!pooledBalancePlan) return; + + const contributionBySourceCustomerEntitlementId = new Map< + string, + Pick + >( + [ + ...pooledBalancePlan.insertPoolContributions, + ...pooledBalancePlan.updatePoolContributions, + ].map((contribution) => [ + contribution.source_customer_entitlement_id, + contribution, + ]), + ); + + for (const customerProduct of customerProducts) { + for (const customerEntitlement of customerProduct.customer_entitlements) { + const contribution = contributionBySourceCustomerEntitlementId.get( + customerEntitlement.id, + ); + if (!contribution) continue; + + normalizePooledBalanceContributionCustomerEntitlement({ + contributionCustomerEntitlement: customerEntitlement, + contribution, + }); + } + } +}; diff --git a/server/src/internal/billing/v2/pooledBalances/compute/computeAttachPooledBalancePlan.ts b/server/src/internal/billing/v2/pooledBalances/compute/computeAttachPooledBalancePlan.ts index 59e1f25161..e3ffa3641e 100644 --- a/server/src/internal/billing/v2/pooledBalances/compute/computeAttachPooledBalancePlan.ts +++ b/server/src/internal/billing/v2/pooledBalances/compute/computeAttachPooledBalancePlan.ts @@ -4,10 +4,7 @@ import type { PooledBalancePlan, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { applyIncomingPooledBalanceSources } from "./applyIncomingPooledBalanceSources/applyIncomingPooledBalanceSources"; -import { applyOutgoingPooledBalanceSources } from "./applyOutgoingPooledBalanceSources/applyOutgoingPooledBalanceSources"; -import { finalizePooledBalanceComputeContext } from "./context/finalizePooledBalanceComputeContext"; -import { setupPooledBalanceComputeContext } from "./context/setupPooledBalanceComputeContext"; +import { computePooledBalanceTransitionPlan } from "./computePooledBalanceTransitionPlan"; export const computeAttachPooledBalancePlan = ({ ctx, @@ -21,32 +18,23 @@ export const computeAttachPooledBalancePlan = ({ customerProduct: FullCusProduct; pooledBalancePlan?: PooledBalancePlan; } => { - const customerProduct = structuredClone(newCustomerProduct); if (attachBillingContext.planTiming !== "immediate") { - return { customerProduct }; + return { customerProduct: newCustomerProduct }; } - const computeContext = setupPooledBalanceComputeContext({ - pooledCustomerEntitlements: - attachBillingContext.fullCustomer.pooled_customer_entitlements ?? [], - }); - - applyOutgoingPooledBalanceSources({ - computeContext, - customerProduct: attachBillingContext.currentCustomerProduct, - }); - - applyIncomingPooledBalanceSources({ + const { pooledBalancePlan } = computePooledBalanceTransitionPlan({ ctx, - computeContext, - customerProduct, + fullCustomer: attachBillingContext.fullCustomer, + outgoingCustomerProducts: attachBillingContext.currentCustomerProduct + ? [attachBillingContext.currentCustomerProduct] + : [], + incomingCustomerProducts: [newCustomerProduct], stripeSubscriptionId: attachBillingContext.stripeSubscription?.id, - customerCreatedAt: attachBillingContext.fullCustomer.created_at, now: attachBillingContext.currentEpochMs, }); return { - customerProduct, - pooledBalancePlan: finalizePooledBalanceComputeContext({ computeContext }), + customerProduct: newCustomerProduct, + pooledBalancePlan, }; }; diff --git a/server/src/internal/billing/v2/pooledBalances/compute/computePooledBalanceTransitionPlan.ts b/server/src/internal/billing/v2/pooledBalances/compute/computePooledBalanceTransitionPlan.ts new file mode 100644 index 0000000000..2854f0a2ac --- /dev/null +++ b/server/src/internal/billing/v2/pooledBalances/compute/computePooledBalanceTransitionPlan.ts @@ -0,0 +1,72 @@ +import type { + FullCusProduct, + FullCustomer, + PooledBalancePlan, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { applyIncomingPooledBalanceSources } from "./applyIncomingPooledBalanceSources/applyIncomingPooledBalanceSources"; +import { applyOutgoingPooledBalanceSources } from "./applyOutgoingPooledBalanceSources/applyOutgoingPooledBalanceSources"; +import { applyPooledBalancePlanToIncomingCustomerProducts } from "./applyPooledBalancePlanToIncomingCustomerProducts"; +import { setupPooledBalanceComputeContext } from "./context/setupPooledBalanceComputeContext"; +import { finalizePooledBalanceTransitionPlan } from "./finalizePooledBalanceTransitionPlan"; + +export const computePooledBalanceTransitionPlan = ({ + ctx, + fullCustomer, + outgoingCustomerProducts = [], + incomingCustomerProducts: inputIncomingCustomerProducts = [], + stripeSubscriptionId, + now, +}: { + ctx: AutumnContext; + fullCustomer: FullCustomer; + outgoingCustomerProducts?: FullCusProduct[]; + incomingCustomerProducts?: FullCusProduct[]; + stripeSubscriptionId?: string; + now: number; +}): { + pooledBalancePlan?: PooledBalancePlan; +} => { + const incomingCustomerProducts: FullCusProduct[] = []; + const incomingCustomerProductIds = new Set(); + for (const customerProduct of inputIncomingCustomerProducts) { + if (incomingCustomerProductIds.has(customerProduct.id)) continue; + incomingCustomerProductIds.add(customerProduct.id); + incomingCustomerProducts.push(customerProduct); + } + const computeContext = setupPooledBalanceComputeContext({ + pooledCustomerEntitlements: fullCustomer.pooled_customer_entitlements ?? [], + }); + + const outgoingCustomerProductIds = new Set(); + for (const customerProduct of outgoingCustomerProducts) { + if (outgoingCustomerProductIds.has(customerProduct.id)) continue; + outgoingCustomerProductIds.add(customerProduct.id); + applyOutgoingPooledBalanceSources({ + computeContext, + customerProduct, + }); + } + + for (const customerProduct of incomingCustomerProducts) { + applyIncomingPooledBalanceSources({ + ctx, + computeContext, + customerProduct, + stripeSubscriptionId: + stripeSubscriptionId ?? customerProduct.subscription_ids?.[0], + customerCreatedAt: fullCustomer.created_at, + now, + }); + } + + const pooledBalancePlan = finalizePooledBalanceTransitionPlan({ + pooledBalancePlan: computeContext.plan, + }); + applyPooledBalancePlanToIncomingCustomerProducts({ + customerProducts: incomingCustomerProducts, + pooledBalancePlan, + }); + + return { pooledBalancePlan }; +}; diff --git a/server/src/internal/billing/v2/pooledBalances/compute/context/finalizePooledBalanceComputeContext.ts b/server/src/internal/billing/v2/pooledBalances/compute/context/finalizePooledBalanceComputeContext.ts deleted file mode 100644 index 8c72ca528a..0000000000 --- a/server/src/internal/billing/v2/pooledBalances/compute/context/finalizePooledBalanceComputeContext.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { PooledBalancePlan } from "@autumn/shared"; -import { pooledBalancePlanHasChanges } from "@/internal/billing/v2/utils/billingPlan/pooledBalancePlan"; -import type { PooledBalanceComputeContext } from "../types/pooledBalanceComputeTypes"; - -export const finalizePooledBalanceComputeContext = ({ - computeContext, -}: { - computeContext: PooledBalanceComputeContext; -}): PooledBalancePlan | undefined => { - return pooledBalancePlanHasChanges({ - pooledBalancePlan: computeContext.plan, - }) - ? computeContext.plan - : undefined; -}; diff --git a/server/src/internal/billing/v2/pooledBalances/compute/finalizePooledBalanceTransitionPlan.ts b/server/src/internal/billing/v2/pooledBalances/compute/finalizePooledBalanceTransitionPlan.ts new file mode 100644 index 0000000000..e05067b0e6 --- /dev/null +++ b/server/src/internal/billing/v2/pooledBalances/compute/finalizePooledBalanceTransitionPlan.ts @@ -0,0 +1,88 @@ +import type { + DbPooledBalanceContribution, + InsertPooledBalanceContribution, + PooledBalancePlan, +} from "@autumn/shared"; +import { pooledBalancePlanHasChanges } from "@/internal/billing/v2/utils/billingPlan/pooledBalancePlan"; + +const contributionValuesMatch = ({ + current, + incoming, +}: { + current: DbPooledBalanceContribution; + incoming: InsertPooledBalanceContribution; +}) => + current.pooled_balance_id === incoming.pooled_balance_id && + current.current_contribution === incoming.current_contribution && + current.next_cycle_contribution === incoming.next_cycle_contribution && + (current.effective_at ?? null) === (incoming.effective_at ?? null); + +const toContributionUpdate = ({ + current, + incoming, +}: { + current: DbPooledBalanceContribution; + incoming: InsertPooledBalanceContribution; +}): DbPooledBalanceContribution => ({ + ...current, + pooled_balance_id: incoming.pooled_balance_id, + current_contribution: incoming.current_contribution ?? 0, + next_cycle_contribution: incoming.next_cycle_contribution ?? 0, + effective_at: incoming.effective_at ?? null, + updated_at: incoming.updated_at ?? current.updated_at, +}); + +export const finalizePooledBalanceTransitionPlan = ({ + pooledBalancePlan, +}: { + pooledBalancePlan: PooledBalancePlan; +}): PooledBalancePlan | undefined => { + const deletedContributionBySourceEntitlementId = new Map( + pooledBalancePlan.deletePoolContributions.map((contribution) => [ + contribution.source_customer_entitlement_id, + contribution, + ]), + ); + const reconciledDeletedContributionIds = new Set(); + const contributionUpdatesById = new Map( + pooledBalancePlan.updatePoolContributions.map((contribution) => [ + contribution.id, + contribution, + ]), + ); + const insertPoolContributions: InsertPooledBalanceContribution[] = []; + + for (const incoming of pooledBalancePlan.insertPoolContributions) { + const current = deletedContributionBySourceEntitlementId.get( + incoming.source_customer_entitlement_id, + ); + if (!current || reconciledDeletedContributionIds.has(current.id)) { + insertPoolContributions.push(incoming); + continue; + } + + reconciledDeletedContributionIds.add(current.id); + if (!contributionValuesMatch({ current, incoming })) { + contributionUpdatesById.set( + current.id, + toContributionUpdate({ current, incoming }), + ); + } + } + + const finalizedPlan: PooledBalancePlan = { + ...pooledBalancePlan, + updatePoolBalances: pooledBalancePlan.updatePoolBalances.filter( + (update) => update.balanceDelta !== 0 || update.grantedDelta !== 0, + ), + insertPoolContributions, + updatePoolContributions: Array.from(contributionUpdatesById.values()), + deletePoolContributions: pooledBalancePlan.deletePoolContributions.filter( + (contribution) => !reconciledDeletedContributionIds.has(contribution.id), + ), + }; + + return pooledBalancePlanHasChanges({ pooledBalancePlan: finalizedPlan }) + ? finalizedPlan + : undefined; +}; diff --git a/server/src/internal/billing/v2/pooledBalances/compute/utils/pooledBalancePlanUtils.ts b/server/src/internal/billing/v2/pooledBalances/compute/utils/pooledBalancePlanUtils.ts index 48809b6333..2b0b2faf85 100644 --- a/server/src/internal/billing/v2/pooledBalances/compute/utils/pooledBalancePlanUtils.ts +++ b/server/src/internal/billing/v2/pooledBalances/compute/utils/pooledBalancePlanUtils.ts @@ -1,59 +1,83 @@ import type { - DbPooledBalanceContribution, - InsertPooledBalanceContribution, - PooledBalancePlan, + DbPooledBalanceContribution, + InsertPooledBalanceContribution, + PooledBalancePlan, } from "@autumn/shared"; +import { addSafe, subtractSafe } from "@autumn/shared"; import type { MutablePooledCustomerEntitlement } from "../types/pooledBalanceComputeTypes"; export const addToUpdatePoolBalances = ({ - pooledBalancePlan, - pooledCustomerEntitlement, - balance, - granted, + pooledBalancePlan, + pooledCustomerEntitlement, + balance, + granted, }: { - pooledBalancePlan: PooledBalancePlan; - pooledCustomerEntitlement: MutablePooledCustomerEntitlement; - balance: number; - granted: number; + pooledBalancePlan: PooledBalancePlan; + pooledCustomerEntitlement: MutablePooledCustomerEntitlement; + balance: number; + granted: number; }) => { - pooledCustomerEntitlement.balance = balance; - pooledCustomerEntitlement.pooled_balance.granted = granted; + const balanceDelta = subtractSafe({ + left: balance, + right: pooledCustomerEntitlement.balance, + }); + const grantedDelta = subtractSafe({ + left: granted, + right: pooledCustomerEntitlement.pooled_balance.granted, + }); + pooledCustomerEntitlement.balance = balance; + pooledCustomerEntitlement.pooled_balance.granted = granted; - const isInsertedInPlan = pooledBalancePlan.insertPoolBalances.some( - (insertedPooledCustomerEntitlement) => - insertedPooledCustomerEntitlement.id === pooledCustomerEntitlement.id, - ); - if (isInsertedInPlan) return; + const isInsertedInPlan = pooledBalancePlan.insertPoolBalances.some( + (insertedPooledCustomerEntitlement) => + insertedPooledCustomerEntitlement.id === pooledCustomerEntitlement.id, + ); + if (isInsertedInPlan) return; - const existingUpdateIndex = pooledBalancePlan.updatePoolBalances.findIndex( - (updatedPooledCustomerEntitlement) => - updatedPooledCustomerEntitlement.id === pooledCustomerEntitlement.id, - ); - if (existingUpdateIndex === -1) { - pooledBalancePlan.updatePoolBalances.push(pooledCustomerEntitlement); - return; - } + const existingUpdateIndex = pooledBalancePlan.updatePoolBalances.findIndex( + (update) => + update.pooledCustomerEntitlement.id === pooledCustomerEntitlement.id, + ); + if (existingUpdateIndex === -1) { + pooledBalancePlan.updatePoolBalances.push({ + pooledCustomerEntitlement, + balanceDelta, + grantedDelta, + }); + return; + } - pooledBalancePlan.updatePoolBalances[existingUpdateIndex] = - pooledCustomerEntitlement; + const existingUpdate = + pooledBalancePlan.updatePoolBalances[existingUpdateIndex]; + pooledBalancePlan.updatePoolBalances[existingUpdateIndex] = { + pooledCustomerEntitlement, + balanceDelta: addSafe({ + left: existingUpdate.balanceDelta, + right: balanceDelta, + }), + grantedDelta: addSafe({ + left: existingUpdate.grantedDelta, + right: grantedDelta, + }), + }; }; export const addToInsertPoolContributions = ({ - pooledBalancePlan, - contribution, + pooledBalancePlan, + contribution, }: { - pooledBalancePlan: PooledBalancePlan; - contribution: InsertPooledBalanceContribution; + pooledBalancePlan: PooledBalancePlan; + contribution: InsertPooledBalanceContribution; }) => { - pooledBalancePlan.insertPoolContributions.push(contribution); + pooledBalancePlan.insertPoolContributions.push(contribution); }; export const addToDeletePoolContributions = ({ - pooledBalancePlan, - contribution, + pooledBalancePlan, + contribution, }: { - pooledBalancePlan: PooledBalancePlan; - contribution: DbPooledBalanceContribution; + pooledBalancePlan: PooledBalancePlan; + contribution: DbPooledBalanceContribution; }) => { - pooledBalancePlan.deletePoolContributions.push(contribution); + pooledBalancePlan.deletePoolContributions.push(contribution); }; diff --git a/server/src/internal/billing/v2/pooledBalances/execute/applyPooledBalanceCustomerProductTransitions.ts b/server/src/internal/billing/v2/pooledBalances/execute/applyPooledBalanceCustomerProductTransitions.ts new file mode 100644 index 0000000000..c80db0c51c --- /dev/null +++ b/server/src/internal/billing/v2/pooledBalances/execute/applyPooledBalanceCustomerProductTransitions.ts @@ -0,0 +1,110 @@ +import { + type FullCusProduct, + type FullCustomer, + findCustomerProductById, + PooledBalanceResetMode, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { CusService } from "@/internal/customers/CusService"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer"; +import { computePooledBalanceTransitionPlan } from "../compute/computePooledBalanceTransitionPlan"; +import { executePooledBalancePlan } from "./executePooledBalancePlan"; +import { resetPooledBalances } from "./resetPooledBalances"; + +export const applyPooledBalanceCustomerProductTransitions = async ({ + ctx, + fullCustomer, + outgoingCustomerProducts, + incomingCustomerProducts, + now, +}: { + ctx: AutumnContext; + fullCustomer: FullCustomer; + outgoingCustomerProducts: FullCusProduct[]; + incomingCustomerProducts: FullCusProduct[]; + now: number; +}): Promise => { + const customerId = fullCustomer.id || fullCustomer.internal_id; + const refreshedBeforeReset = await refreshFullCustomer({ + ctx, + customerId, + source: "pooled-balance-lifecycle-before-reset", + }); + const duePooledCustomerEntitlements = ( + refreshedBeforeReset.pooled_customer_entitlements ?? [] + ).filter( + (customerEntitlement) => + customerEntitlement.pooled_balance?.reset_mode === + PooledBalanceResetMode.Subscription && + customerEntitlement.next_reset_at !== null && + customerEntitlement.next_reset_at <= now, + ); + await resetPooledBalances({ + ctx, + fullCustomer: refreshedBeforeReset, + pooledCustomerEntitlements: duePooledCustomerEntitlements, + source: "pooled-balance-lifecycle-reset", + }); + const refreshedFullCustomer = await refreshFullCustomer({ + ctx, + customerId, + source: "pooled-balance-lifecycle-after-reset", + }); + const refreshedOutgoingCustomerProducts = refreshCustomerProducts({ + fullCustomer: refreshedFullCustomer, + customerProducts: outgoingCustomerProducts, + }); + const refreshedIncomingCustomerProducts = refreshCustomerProducts({ + fullCustomer: refreshedFullCustomer, + customerProducts: incomingCustomerProducts, + }); + const { pooledBalancePlan } = computePooledBalanceTransitionPlan({ + ctx, + fullCustomer: refreshedFullCustomer, + outgoingCustomerProducts: refreshedOutgoingCustomerProducts, + incomingCustomerProducts: refreshedIncomingCustomerProducts, + now, + }); + if (!pooledBalancePlan) return; + + await executePooledBalancePlan({ ctx, pooledBalancePlan }); + await deleteCachedFullCustomer({ + ctx, + customerId, + source: "pooled-balance-lifecycle-transition", + }); +}; + +const refreshFullCustomer = async ({ + ctx, + customerId, + source, +}: { + ctx: AutumnContext; + customerId: string; + source: string; +}) => { + await deleteCachedFullCustomer({ ctx, customerId, source }); + return CusService.getFull({ + ctx, + idOrInternalId: customerId, + withEntities: true, + withSubs: true, + skipReset: true, + }); +}; + +const refreshCustomerProducts = ({ + fullCustomer, + customerProducts, +}: { + fullCustomer: FullCustomer; + customerProducts: FullCusProduct[]; +}) => + customerProducts.map( + (customerProduct) => + findCustomerProductById({ + fullCustomer, + customerProductId: customerProduct.id, + }) ?? customerProduct, + ); diff --git a/server/src/internal/billing/v2/pooledBalances/execute/executePooledBalancePlan.ts b/server/src/internal/billing/v2/pooledBalances/execute/executePooledBalancePlan.ts index d719ffc930..0fb965b8f8 100644 --- a/server/src/internal/billing/v2/pooledBalances/execute/executePooledBalancePlan.ts +++ b/server/src/internal/billing/v2/pooledBalances/execute/executePooledBalancePlan.ts @@ -59,7 +59,11 @@ export const executePooledBalancePlan = async ({ await tx.insert(pooledBalances).values(pooledBalance); } - for (const fullCustomerEntitlement of pooledBalancePlan.updatePoolBalances) { + for (const { + pooledCustomerEntitlement: fullCustomerEntitlement, + balanceDelta, + grantedDelta, + } of pooledBalancePlan.updatePoolBalances) { const pooledBalance = fullCustomerEntitlement.pooled_balance; if (!pooledBalance) { throw new InternalError({ @@ -70,13 +74,7 @@ export const executePooledBalancePlan = async ({ await tx .update(customerEntitlements) .set({ - balance: fullCustomerEntitlement.balance ?? 0, - adjustment: fullCustomerEntitlement.adjustment ?? 0, - additional_balance: fullCustomerEntitlement.additional_balance ?? 0, - entities: fullCustomerEntitlement.entities ?? null, - reset_cycle_anchor: - fullCustomerEntitlement.reset_cycle_anchor ?? null, - next_reset_at: fullCustomerEntitlement.next_reset_at, + balance: sql`COALESCE(${customerEntitlements.balance}, 0) + ${balanceDelta}`, cache_version: sql`${customerEntitlements.cache_version} + 1`, }) .where(eq(customerEntitlements.id, fullCustomerEntitlement.id)); @@ -84,11 +82,10 @@ export const executePooledBalancePlan = async ({ await tx .update(pooledBalances) .set({ - granted: pooledBalance.granted, + granted: sql`COALESCE(${pooledBalances.granted}, 0) + ${grantedDelta}`, reset_cycle_anchor: pooledBalance.reset_cycle_anchor, stripe_subscription_id: pooledBalance.stripe_subscription_id, customer_license_link_id: pooledBalance.customer_license_link_id, - last_applied_reset_at: pooledBalance.last_applied_reset_at, updated_at: Date.now(), }) .where(eq(pooledBalances.id, pooledBalance.id)); @@ -102,7 +99,14 @@ export const executePooledBalancePlan = async ({ for (const contribution of pooledBalancePlan.insertPoolContributions) { await tx .update(customerEntitlements) - .set({ pooled_contribution_id: contribution.id }) + .set({ + pooled_contribution_id: contribution.id, + pooled_balance_id: null, + balance: 0, + adjustment: 0, + additional_balance: 0, + entities: null, + }) .where( eq( customerEntitlements.id, diff --git a/server/src/internal/billing/v2/pooledBalances/execute/resetPooledBalances.ts b/server/src/internal/billing/v2/pooledBalances/execute/resetPooledBalances.ts new file mode 100644 index 0000000000..2b07f55603 --- /dev/null +++ b/server/src/internal/billing/v2/pooledBalances/execute/resetPooledBalances.ts @@ -0,0 +1,60 @@ +import type { FullCustomer, FullCustomerEntitlement } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { resetCusEnts } from "@/internal/balances/utils/sql/client.js"; +import { applyResetResults } from "@/internal/customers/actions/resetCustomerEntitlements/applyResetResults.js"; +import { + type ProcessResetResult, + processReset, +} from "@/internal/customers/actions/resetCustomerEntitlements/processReset.js"; +import { processResetResultToResetCusEntParam } from "@/internal/customers/actions/resetCustomerEntitlements/processResetResultToResetCusEntParam.js"; +import { invalidateCachedFullSubject } from "@/internal/customers/cache/fullSubject/index.js"; + +export const resetPooledBalances = async ({ + ctx, + fullCustomer, + pooledCustomerEntitlements, + source, +}: { + ctx: AutumnContext; + fullCustomer: FullCustomer; + pooledCustomerEntitlements: FullCustomerEntitlement[]; + source: string; +}) => { + const computed: Array<{ + cusEntId: string; + result: ProcessResetResult; + }> = []; + + for (const customerEntitlement of pooledCustomerEntitlements) { + const result = await processReset({ + ctx, + cusEnt: { ...customerEntitlement, customer_product: null }, + }); + if (result) computed.push({ cusEntId: customerEntitlement.id, result }); + } + + if (computed.length === 0) return; + + const resets = computed.map(({ cusEntId, result }) => + processResetResultToResetCusEntParam({ + customerEntitlementId: cusEntId, + result, + }), + ); + const { applied, skipped } = await resetCusEnts({ ctx, resets }); + + await applyResetResults({ + ctx, + fullCus: fullCustomer, + computed, + skipped, + }); + + if (Object.keys(applied).length > 0) { + await invalidateCachedFullSubject({ + ctx, + customerId: fullCustomer.id ?? fullCustomer.internal_id, + source, + }); + } +}; diff --git a/server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/normalizePooledBalanceContributionCustomerEntitlement.ts b/server/src/internal/billing/v2/pooledBalances/normalizePooledBalanceContributionCustomerEntitlement.ts similarity index 91% rename from server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/normalizePooledBalanceContributionCustomerEntitlement.ts rename to server/src/internal/billing/v2/pooledBalances/normalizePooledBalanceContributionCustomerEntitlement.ts index 076fcbc8eb..c4da949787 100644 --- a/server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/normalizePooledBalanceContributionCustomerEntitlement.ts +++ b/server/src/internal/billing/v2/pooledBalances/normalizePooledBalanceContributionCustomerEntitlement.ts @@ -8,7 +8,7 @@ export const normalizePooledBalanceContributionCustomerEntitlement = ({ contribution, }: { contributionCustomerEntitlement: FullCustomerEntitlement; - contribution: InsertPooledBalanceContribution; + contribution: Pick; }) => { contributionCustomerEntitlement.pooled_contribution_id = contribution.id; contributionCustomerEntitlement.pooled_balance_id = null; diff --git a/server/src/internal/billing/v2/providers/stripe/errors/handleStripeBillingPlanErrors.ts b/server/src/internal/billing/v2/providers/stripe/errors/handleStripeBillingPlanErrors.ts index c088d214d7..ce80603a7d 100644 --- a/server/src/internal/billing/v2/providers/stripe/errors/handleStripeBillingPlanErrors.ts +++ b/server/src/internal/billing/v2/providers/stripe/errors/handleStripeBillingPlanErrors.ts @@ -1,7 +1,62 @@ import type { BillingContext, BillingPlan } from "@autumn/shared"; -import { ErrCode, InternalError } from "@autumn/shared"; +import { + ErrCode, + InternalError, + isFixedPrice, + notNullish, + RecaseError, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { validatePromotionCodeMinimums } from "@/internal/billing/v2/providers/stripe/errors/validatePromotionCodeMinimums"; +import { lineItemToStripeProductId } from "@/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemToStripeProductId"; + +const getPlannedStripeProductIds = ({ + billingPlan, +}: { + billingPlan: BillingPlan; +}) => + new Set( + [ + ...(billingPlan.autumn.lineItems ?? []).map((lineItem) => + lineItemToStripeProductId({ lineItem }), + ), + ...billingPlan.autumn.insertCustomerProducts.flatMap((customerProduct) => + customerProduct.customer_prices.map(({ price }) => + isFixedPrice(price) + ? customerProduct.product.processor?.id + : price.config.stripe_product_id, + ), + ), + ].filter(notNullish), + ); + +const validatePromotionCodeProducts = ({ + billingContext, + billingPlan, +}: { + billingContext: BillingContext; + billingPlan: BillingPlan; +}) => { + const plannedStripeProductIds = getPlannedStripeProductIds({ billingPlan }); + if (plannedStripeProductIds.size === 0) return; + + const invalidPromotionCode = billingContext.stripeDiscounts?.find( + (discount) => + !discount.id && + discount.promotionCodeId && + discount.source.coupon.applies_to?.products?.length && + !discount.source.coupon.applies_to.products.some((productId) => + plannedStripeProductIds.has(productId), + ), + ); + if (!invalidPromotionCode) return; + + throw new RecaseError({ + message: "Promotion code does not apply to any products in this order.", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); +}; /** * Validates Stripe-specific billing context requirements before executing billing plan. @@ -19,6 +74,7 @@ export const handleStripeBillingPlanErrors = ({ const { stripeSubscriptionSchedule } = billingContext; const { subscriptionScheduleAction } = billingPlan.stripe; + validatePromotionCodeProducts({ billingContext, billingPlan }); validatePromotionCodeMinimums({ ctx, billingContext, billingPlan }); if (subscriptionScheduleAction?.type !== "update") return; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts index 33606c5b2b..8a49b83fca 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts @@ -3,6 +3,7 @@ import type { StripeSubscriptionScheduleAction, } from "@autumn/shared"; import { createStripeCli } from "@server/external/connect/createStripeCli"; +import { autumnStripeRequestOptions } from "@server/external/stripe/common/autumnStripeIdempotency"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; import type Stripe from "stripe"; import { findMatchingInlinePriceIdForPhaseItem } from "@/internal/billing/v2/providers/stripe/utils/matchUtils/matchStripeInlinePrice"; @@ -146,9 +147,12 @@ const createScheduleFromSubscription = async ({ params: Stripe.SubscriptionScheduleUpdateParams; stripeSubscription?: Stripe.Subscription; }): Promise => { - const schedule = await stripeCli.subscriptionSchedules.create({ - from_subscription: subscriptionId, - }); + const schedule = await stripeCli.subscriptionSchedules.create( + { + from_subscription: subscriptionId, + }, + autumnStripeRequestOptions({ source: "schedule" }), + ); const phases = buildAnchoredPhases({ params, @@ -156,10 +160,14 @@ const createScheduleFromSubscription = async ({ stripeSubscription, }); - return await stripeCli.subscriptionSchedules.update(schedule.id, { - phases, - end_behavior: params.end_behavior, - }); + return await stripeCli.subscriptionSchedules.update( + schedule.id, + { + phases, + end_behavior: params.end_behavior, + }, + autumnStripeRequestOptions({ source: "schedule" }), + ); }; const getStandaloneScheduleDefaults = ({ @@ -222,13 +230,16 @@ export const executeStripeSubscriptionScheduleAction = async ({ // No subscription - create standalone schedule const startDate = params.phases?.[0]?.start_date; - return await stripeCli.subscriptionSchedules.create({ - customer: billingContext.stripeCustomer?.id ?? "none", - phases: params.phases?.map(toCreatePhase) ?? [], - end_behavior: params.end_behavior, - start_date: startDate, - ...getStandaloneScheduleDefaults({ billingContext }), - }); + return await stripeCli.subscriptionSchedules.create( + { + customer: billingContext.stripeCustomer?.id ?? "none", + phases: params.phases?.map(toCreatePhase) ?? [], + end_behavior: params.end_behavior, + start_date: startDate, + ...getStandaloneScheduleDefaults({ billingContext }), + }, + autumnStripeRequestOptions({ source: billingContext.actionSource }), + ); } case "update": { @@ -244,6 +255,7 @@ export const executeStripeSubscriptionScheduleAction = async ({ return await stripeCli.subscriptionSchedules.update( stripeSubscriptionScheduleId, params, + autumnStripeRequestOptions({ source: billingContext.actionSource }), ); } @@ -251,6 +263,8 @@ export const executeStripeSubscriptionScheduleAction = async ({ // The subscription may have been updated first, changing its items await stripeCli.subscriptionSchedules.release( stripeSubscriptionScheduleId, + {}, + autumnStripeRequestOptions({ source: billingContext.actionSource }), ); const newSchedule = await createScheduleFromSubscription({ @@ -281,6 +295,8 @@ export const executeStripeSubscriptionScheduleAction = async ({ ); await stripeCli.subscriptionSchedules.release( subscriptionScheduleAction.stripeSubscriptionScheduleId, + {}, + autumnStripeRequestOptions({ source: billingContext.actionSource }), ); return null; @@ -290,6 +306,8 @@ export const executeStripeSubscriptionScheduleAction = async ({ ); await stripeCli.subscriptionSchedules.cancel( subscriptionScheduleAction.stripeSubscriptionScheduleId, + {}, + autumnStripeRequestOptions({ source: billingContext.actionSource }), ); return null; } diff --git a/server/src/internal/billing/v2/providers/stripe/utils/checkoutSessions/createStripeSessionWithCardFallback.ts b/server/src/internal/billing/v2/providers/stripe/utils/checkoutSessions/createStripeSessionWithCardFallback.ts index 96af3ab6c0..040373caf4 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/checkoutSessions/createStripeSessionWithCardFallback.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/checkoutSessions/createStripeSessionWithCardFallback.ts @@ -1,5 +1,6 @@ import type Stripe from "stripe"; import type { createStripeCli } from "@/external/connect/createStripeCli"; +import { autumnStripeRequestOptions } from "@/external/stripe/common/autumnStripeIdempotency"; /** * Creates a Stripe checkout session, retrying with explicit `payment_method_types: ["card"]` @@ -13,15 +14,22 @@ export const createStripeSessionWithCardFallback = async ({ params: Stripe.Checkout.SessionCreateParams; }) => { try { - return await stripeCli.checkout.sessions.create(params); + return await stripeCli.checkout.sessions.create( + params, + autumnStripeRequestOptions({ source: "checkout.session" }), + ); } catch (error) { const msg = error instanceof Error ? error.message : undefined; if (msg?.includes("payment method") || msg?.includes("No valid payment")) { - return stripeCli.checkout.sessions.create({ - ...params, - payment_method_types: ["card"], - }); + // Fresh key: retry params differ, so reusing the first key would 400. + return stripeCli.checkout.sessions.create( + { + ...params, + payment_method_types: ["card"], + }, + autumnStripeRequestOptions({ source: "checkout.session" }), + ); } throw error; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts index a3eb962622..342883f56b 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts @@ -21,6 +21,7 @@ import { getPatchCustomerProducts, } from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; import { orgDisableStripeWrites } from "@/internal/orgs/orgUtils/convertOrgUtils"; +import { isStripeConnected } from "@/internal/orgs/orgUtils.js"; import { checkStripeProductExists } from "@/internal/products/productUtils"; import { applyStripeResourceReuseForProduct } from "@/internal/products/stripeResourceUtils/applyStripeResourceReuseForProduct"; import { applyStripeReuseFromVariantFamilies } from "@/internal/products/stripeResourceUtils/applyStripeReuseFromVariantFamilies"; @@ -51,6 +52,9 @@ export const initStripeResourcesForProducts = async ({ if (env === AppEnv.Live) return; if (orgDisableStripeWrites({ ctx, includeSandbox: true })) return; + // No Stripe account (e.g. fresh sandbox sub-orgs) — resources are + // created lazily once one is connected. + if (!isStripeConnected({ org, env })) return; const batchProductUpdates = []; for (const product of products) { diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/payStripeInvoice.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/payStripeInvoice.ts index b58f84ea09..b04fd4e277 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoices/payStripeInvoice.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoices/payStripeInvoice.ts @@ -1,5 +1,6 @@ import { type PaymentFailureCode, tryCatch } from "@autumn/shared"; import type Stripe from "stripe"; +import { autumnStripeRequestOptions } from "@/external/stripe/common/autumnStripeIdempotency"; import { handleInvoicePaymentFailure } from "./handleInvoicePaymentFailure"; // ============================================ @@ -60,9 +61,13 @@ export const payStripeInvoice = async ({ // 4. Attempt payment const { data: paidInvoice, error } = await tryCatch( - stripeCli.invoices.pay(invoice.id, { - payment_method: paymentMethod.id, - }), + stripeCli.invoices.pay( + invoice.id, + { + payment_method: paymentMethod.id, + }, + autumnStripeRequestOptions({ source: "invoice.pay" }), + ), ); if (error) { diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts index 86e45b8056..ab9a335302 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts @@ -1,5 +1,6 @@ import type Stripe from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli"; +import { autumnStripeRequestOptions } from "@/external/stripe/common/autumnStripeIdempotency"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; // ============================================ @@ -37,23 +38,26 @@ export const createStripeInvoice = async ({ }: CreateInvoiceParams): Promise => { // Stripe rejects default_tax_rates alongside automatic_tax; a manual rate takes precedence. const hasManualTaxRates = Boolean(defaultTaxRates?.length); - const invoice = await stripeCli.invoices.create({ - customer: stripeCusId, - auto_advance: false, - ...(stripeSubId ? { subscription: stripeSubId } : {}), - ...(currency ? { currency } : {}), - ...(description ? { description } : {}), - ...(footer ? { footer } : {}), - ...(metadata ? { metadata } : {}), - collection_method: collectionMethod, - days_until_due: - collectionMethod === "send_invoice" ? (daysUntilDue ?? 30) : undefined, - ...(discounts ? { discounts } : {}), - ...(hasManualTaxRates ? { default_tax_rates: defaultTaxRates } : {}), - ...(automaticTax && !hasManualTaxRates - ? { automatic_tax: { enabled: true } } - : {}), - }); + const invoice = await stripeCli.invoices.create( + { + customer: stripeCusId, + auto_advance: false, + ...(stripeSubId ? { subscription: stripeSubId } : {}), + ...(currency ? { currency } : {}), + ...(description ? { description } : {}), + ...(footer ? { footer } : {}), + ...(metadata ? { metadata } : {}), + collection_method: collectionMethod, + days_until_due: + collectionMethod === "send_invoice" ? (daysUntilDue ?? 30) : undefined, + ...(discounts ? { discounts } : {}), + ...(hasManualTaxRates ? { default_tax_rates: defaultTaxRates } : {}), + ...(automaticTax && !hasManualTaxRates + ? { automatic_tax: { enabled: true } } + : {}), + }, + autumnStripeRequestOptions({ source: "invoice.create" }), + ); return invoice; }; @@ -73,9 +77,13 @@ export const addStripeInvoiceLines = async ({ invoiceId, lines, }: AddInvoiceLinesParams): Promise => { - const invoice = await stripeCli.invoices.addLines(invoiceId, { - lines, - }); + const invoice = await stripeCli.invoices.addLines( + invoiceId, + { + lines, + }, + autumnStripeRequestOptions({ source: "invoice.addLines" }), + ); return invoice; }; @@ -95,7 +103,11 @@ export const updateStripeInvoice = async ({ invoiceId, params, }: UpdateInvoiceParams): Promise => { - const invoice = await stripeCli.invoices.update(invoiceId, params); + const invoice = await stripeCli.invoices.update( + invoiceId, + params, + autumnStripeRequestOptions({ source: "invoice.update" }), + ); return invoice; }; @@ -115,9 +127,13 @@ export const finalizeStripeInvoice = async ({ invoiceId, autoAdvance = false, }: FinalizeInvoiceParams): Promise => { - const invoice = await stripeCli.invoices.finalizeInvoice(invoiceId, { - auto_advance: autoAdvance, - }); + const invoice = await stripeCli.invoices.finalizeInvoice( + invoiceId, + { + auto_advance: autoAdvance, + }, + autumnStripeRequestOptions({ source: "invoice.finalize" }), + ); return invoice; }; @@ -138,6 +154,11 @@ export const createStripeInvoiceItems = async ({ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); return Promise.all( - invoiceItems.map((item) => stripeCli.invoiceItems.create(item)), + invoiceItems.map((item) => + stripeCli.invoiceItems.create( + item, + autumnStripeRequestOptions({ source: "invoiceItems.create" }), + ), + ), ); }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerLicenseToStripeItemSpecs.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerLicenseToStripeItemSpecs.ts index 9fd0adf875..329e6aa8f0 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerLicenseToStripeItemSpecs.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerLicenseToStripeItemSpecs.ts @@ -27,8 +27,7 @@ export const customerLicenseToStripeItemSpecs = ({ billingContext.customerLicenseBillingContext?.licenseBillingPriceRows ?? [] ).filter((row) => row.source.customerLicenseId === customerLicense.id); const projectedPlanLicenseIds = - billingContext.customerLicenseBillingContext?.projectedPlanLicenseIds ?? - new Set(); + billingContext.customerLicenseBillingContext?.projectedPlanLicenseIds ?? []; // Desired state prices seats through the pool's (possibly repointed) // definition — the read-time twin of the seat repoint executor. diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts index 90bfe19fd6..745c9ded9a 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts @@ -1,6 +1,7 @@ import type { BillingContext, StripeSubscriptionAction } from "@autumn/shared"; import { InternalError } from "@autumn/shared"; import { createStripeCli } from "@/external/connect/createStripeCli"; +import { autumnStripeRequestOptions } from "@/external/stripe/common/autumnStripeIdempotency"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { buildAutumnSubscriptionMetadata } from "@/internal/billing/v2/providers/stripe/utils/common/autumnStripeMetadata"; import { mergeStripeMetadata } from "@/internal/billing/v2/providers/stripe/utils/common/mergeStripeMetadata"; @@ -53,6 +54,8 @@ export const executeStripeSubscriptionOperation = async ({ ? { default_tax_rates: [billingContext.taxRateId] } : {}; + const idempotencySource = billingContext.actionSource; + switch (subscriptionAction.type) { case "update": { let stripeSubscription = billingContext.stripeSubscription; @@ -66,6 +69,7 @@ export const executeStripeSubscriptionOperation = async ({ { billing_mode: { type: "flexible" }, }, + autumnStripeRequestOptions({ source: idempotencySource }), ); } @@ -85,6 +89,7 @@ export const executeStripeSubscriptionOperation = async ({ payment_behavior: "error_if_incomplete", expand: ["latest_invoice"], }, + autumnStripeRequestOptions({ source: idempotencySource }), ); } @@ -104,27 +109,32 @@ export const executeStripeSubscriptionOperation = async ({ ...taxRateParams, expand: ["latest_invoice"], }, + autumnStripeRequestOptions({ source: idempotencySource }), ); } case "create": - return await stripeClient.subscriptions.create({ - ...subscriptionAction.params, - ...invoiceModeParams, - ...fallbackPaymentMethodParams, - ...(autumnMeta && { metadata: autumnMeta }), - ...(wantsAutoTax ? { automatic_tax: { enabled: true } } : {}), - ...taxRateParams, + return await stripeClient.subscriptions.create( + { + ...subscriptionAction.params, + ...invoiceModeParams, + ...fallbackPaymentMethodParams, + ...(autumnMeta && { metadata: autumnMeta }), + ...(wantsAutoTax ? { automatic_tax: { enabled: true } } : {}), + ...taxRateParams, - billing_mode: { type: "flexible" }, + billing_mode: { type: "flexible" }, - expand: ["latest_invoice"], - }); + expand: ["latest_invoice"], + }, + autumnStripeRequestOptions({ source: idempotencySource }), + ); case "cancel": return await stripeClient.subscriptions.cancel( subscriptionAction.stripeSubscriptionId, { expand: ["latest_invoice"], }, + autumnStripeRequestOptions({ source: idempotencySource }), ); default: diff --git a/server/src/internal/billing/v2/setup/customerLicenseBillingContext/setupCustomerLicenseBillingContext.ts b/server/src/internal/billing/v2/setup/customerLicenseBillingContext/setupCustomerLicenseBillingContext.ts index 0e2c14bdb1..abe888ebe7 100644 --- a/server/src/internal/billing/v2/setup/customerLicenseBillingContext/setupCustomerLicenseBillingContext.ts +++ b/server/src/internal/billing/v2/setup/customerLicenseBillingContext/setupCustomerLicenseBillingContext.ts @@ -19,8 +19,8 @@ export const setupCustomerLicenseBillingContext = async ({ if (customerLicenses.length === 0) { return { licenseBillingPriceRows: [], - assignedSeatCountByCustomerLicenseId: new Map(), - projectedPlanLicenseIds: new Set(), + assignedSeatCountByCustomerLicenseId: {}, + projectedPlanLicenseIds: [], }; } @@ -37,7 +37,7 @@ export const setupCustomerLicenseBillingContext = async ({ ), }), ]); - const assignedSeatCountByCustomerLicenseId = new Map( + const assignedSeatCountByCustomerLicenseId = Object.fromEntries( customerLicenses.map((customerLicense) => [ customerLicense.id, assignedSeatCountByLinkId.get(customerLicense.link_id) ?? 0, @@ -47,6 +47,6 @@ export const setupCustomerLicenseBillingContext = async ({ return { licenseBillingPriceRows, assignedSeatCountByCustomerLicenseId, - projectedPlanLicenseIds: new Set(), + projectedPlanLicenseIds: [], }; }; diff --git a/server/src/internal/billing/v2/setup/patch/handleCustomizePrice.ts b/server/src/internal/billing/v2/setup/patch/handleCustomizePrice.ts index bd45c87448..071893d58a 100644 --- a/server/src/internal/billing/v2/setup/patch/handleCustomizePrice.ts +++ b/server/src/internal/billing/v2/setup/patch/handleCustomizePrice.ts @@ -2,7 +2,6 @@ import type { CustomizePlanV1, FullCusProduct, FullCustomerPrice, - FullProduct, Price, SharedContext, } from "@autumn/shared"; @@ -37,17 +36,21 @@ const removeCurrentBasePrice = ({ return customerPrices; }; +/** Replaces the customer product's base price in place. Callers surface the + * returned prices on their product snapshot, as with the other patch handlers. */ export const handleCustomizePrice = ({ ctx, customize, targetCustomerProduct, - fullProduct, + orgId, + internalProductId, reusePricesAndEntitlements, }: { ctx: SharedContext; customize: CustomizePlanV1; targetCustomerProduct: FullCusProduct; - fullProduct: FullProduct; + orgId: string; + internalProductId: string; reusePricesAndEntitlements?: ReusePricesAndEntitlements; }): { customerPrices: FullCustomerPrice[]; @@ -74,8 +77,8 @@ export const handleCustomizePrice = ({ }); const { newPrice, updatedPrice } = itemToPriceAndEnt({ item, - orgId: fullProduct.org_id, - internalProductId: fullProduct.internal_id, + orgId, + internalProductId, isCustom: true, features: ctx.features, }); @@ -83,7 +86,5 @@ export const handleCustomizePrice = ({ } const prices = price ? [price] : []; - fullProduct.prices.push(...prices); - return { customerPrices, prices }; }; diff --git a/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts b/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts index 3af0399287..b0fd5b925e 100644 --- a/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts +++ b/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts @@ -4,12 +4,15 @@ import { type FullCusProduct, type FullProduct, isCustomizePlanPatchStyle, + mapToProductItems, + orgMultiCurrencyEnabled, type PatchContext, type SharedContext, type UpdateSubscriptionV1Params, } from "@autumn/shared"; import { isFixedPrice } from "@shared/utils/productUtils/priceUtils/classifyPriceUtils"; import { duplicateCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/duplicateCustomerProduct"; +import { validateProductItems } from "@/internal/products/product-items/validateProductItems"; import { generateId } from "@/utils/genUtils"; import { handleCustomizeAddItems } from "./handleCustomizeAddItems"; import { handleCustomizeDeleteItems } from "./handleCustomizeDeleteItems"; @@ -153,6 +156,20 @@ export const setupPatchContext = ({ features: ctx.features, }); + const { + customerPrices: deletePriceCustomerPrices, + prices: customPricePrices, + } = handleCustomizePrice({ + ctx, + customize, + targetCustomerProduct: finalCustomerProduct, + orgId: finalCustomerProduct.product.org_id, + internalProductId: finalCustomerProduct.product.internal_id, + reusePricesAndEntitlements, + }); + + // Snapshot only after every handler that mutates finalCustomerProduct, so it + // can't drift from the customer product it's derived from. const patchFullProduct = cusProductToProduct({ cusProduct: finalCustomerProduct, }); @@ -167,18 +184,7 @@ export const setupPatchContext = ({ if (!feature) continue; patchFullProduct.entitlements.push({ ...newEnt, feature }); } - patchFullProduct.prices.push(...updateNewPrices); - - const { - customerPrices: deletePriceCustomerPrices, - prices: customPricePrices, - } = handleCustomizePrice({ - ctx, - customize, - targetCustomerProduct: finalCustomerProduct, - fullProduct: patchFullProduct, - reusePricesAndEntitlements, - }); + patchFullProduct.prices.push(...updateNewPrices, ...customPricePrices); const { addItems: nonNoopAddItems } = handleCustomizeNoopItems({ customize, @@ -194,6 +200,18 @@ export const setupPatchContext = ({ reusePricesAndEntitlements, }); + validateProductItems({ + newItems: mapToProductItems({ + prices: patchFullProduct.prices, + entitlements: patchFullProduct.entitlements, + features: ctx.features, + }), + features: ctx.features, + orgId: patchFullProduct.org_id, + env: patchFullProduct.env, + multiCurrencyEnabled: orgMultiCurrencyEnabled({ org: ctx.org }), + }); + const patchContext: PatchContext = { originalCustomerProduct: customerProduct, mode, diff --git a/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts b/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts index 23a06623ff..808ca8eb57 100644 --- a/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts +++ b/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts @@ -5,6 +5,7 @@ import type { FullCustomer, } from "@autumn/shared"; import { isSameRowTransition } from "@/internal/billing/v2/compute/customerLicenseTransitions/isSameRowTransition"; +import { applyPooledBalancePlanToFullCustomer } from "@/internal/billing/v2/utils/billingPlan/applyPooledBalancePlanToFullCustomer"; import { applyCustomerProductPatch, applyCustomerProductUpdate, @@ -185,13 +186,15 @@ export const applyAutumnBillingPlanToFullCustomer = ({ customerProducts, autumnBillingPlan, }); + finalFullCustomer.customer_products = customerProducts; + applyPooledBalancePlanToFullCustomer({ + fullCustomer: finalFullCustomer, + pooledBalancePlan: autumnBillingPlan.pooledBalancePlan, + }); // 6. Return final full customer. Customer licenses otherwise need no merge // — they ride each customer product (hydration-stitched or init-planned). - return { - ...finalFullCustomer, - customer_products: customerProducts, - }; + return finalFullCustomer; }; export const autumnBillingPlanToFinalFullCustomer = ({ diff --git a/server/src/internal/billing/v2/utils/billingPlan/applyPooledBalancePlanToFullCustomer.ts b/server/src/internal/billing/v2/utils/billingPlan/applyPooledBalancePlanToFullCustomer.ts new file mode 100644 index 0000000000..d09f6288ea --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingPlan/applyPooledBalancePlanToFullCustomer.ts @@ -0,0 +1,85 @@ +import type { + DbPooledBalanceContribution, + FullCustomer, + FullCustomerEntitlement, + PooledBalancePlan, +} from "@autumn/shared"; +import { normalizePooledBalanceContributionCustomerEntitlement } from "@/internal/billing/v2/pooledBalances/normalizePooledBalanceContributionCustomerEntitlement"; + +export const applyPooledBalancePlanToFullCustomer = ({ + fullCustomer, + pooledBalancePlan, +}: { + fullCustomer: FullCustomer; + pooledBalancePlan?: PooledBalancePlan; +}) => { + if (!pooledBalancePlan) return; + + const sourceCustomerEntitlementById = new Map( + fullCustomer.customer_products + .flatMap((customerProduct) => customerProduct.customer_entitlements) + .map((customerEntitlement) => [ + customerEntitlement.id, + customerEntitlement, + ]), + ); + const deletedContributionIds = new Set( + pooledBalancePlan.deletePoolContributions.map( + (contribution) => contribution.id, + ), + ); + + for (const customerEntitlement of sourceCustomerEntitlementById.values()) { + if ( + !customerEntitlement.pooled_contribution_id || + !deletedContributionIds.has(customerEntitlement.pooled_contribution_id) + ) { + continue; + } + + customerEntitlement.pooled_contribution_id = null; + customerEntitlement.pooled_balance_contribution = undefined; + } + + for (const contribution of [ + ...pooledBalancePlan.insertPoolContributions, + ...pooledBalancePlan.updatePoolContributions, + ]) { + const customerEntitlement = sourceCustomerEntitlementById.get( + contribution.source_customer_entitlement_id, + ); + if (!customerEntitlement) continue; + + normalizePooledBalanceContributionCustomerEntitlement({ + contributionCustomerEntitlement: customerEntitlement, + contribution, + }); + customerEntitlement.pooled_balance_contribution = + contribution as DbPooledBalanceContribution; + } + + const pooledCustomerEntitlementById = new Map< + string, + FullCustomerEntitlement + >( + (fullCustomer.pooled_customer_entitlements ?? []).map( + (customerEntitlement) => [customerEntitlement.id, customerEntitlement], + ), + ); + for (const customerEntitlement of pooledBalancePlan.insertPoolBalances) { + pooledCustomerEntitlementById.set( + customerEntitlement.id, + structuredClone(customerEntitlement), + ); + } + for (const update of pooledBalancePlan.updatePoolBalances) { + pooledCustomerEntitlementById.set( + update.pooledCustomerEntitlement.id, + structuredClone(update.pooledCustomerEntitlement), + ); + } + + fullCustomer.pooled_customer_entitlements = Array.from( + pooledCustomerEntitlementById.values(), + ); +}; diff --git a/server/src/internal/billing/v2/utils/billingPlan/customerProductPlanMutations.ts b/server/src/internal/billing/v2/utils/billingPlan/customerProductPlanMutations.ts index e1efb12ef5..50f62fb1d7 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/customerProductPlanMutations.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/customerProductPlanMutations.ts @@ -30,6 +30,17 @@ export const getPatchCustomerProducts = ({ autumnBillingPlan: AutumnBillingPlan; }) => autumnBillingPlan.patchCustomerProducts ?? []; +export const getCustomerProductPlanOperations = ({ + autumnBillingPlan, +}: { + autumnBillingPlan: AutumnBillingPlan; +}) => ({ + inserts: autumnBillingPlan.insertCustomerProducts, + updates: getUpdateCustomerProducts({ autumnBillingPlan }), + deletes: getDeleteCustomerProducts({ autumnBillingPlan }), + patches: getPatchCustomerProducts({ autumnBillingPlan }), +}); + export const getPatchedCustomerProductUpdates = ({ autumnBillingPlan, }: { diff --git a/server/src/internal/billing/v2/utils/billingPlan/getPreviousValues.ts b/server/src/internal/billing/v2/utils/billingPlan/getPreviousValues.ts new file mode 100644 index 0000000000..8dfd24666b --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingPlan/getPreviousValues.ts @@ -0,0 +1,10 @@ +export const getPreviousValues = ({ + before, + updates, +}: { + before: T; + updates: U; +}): U => + Object.fromEntries( + Object.keys(updates).map((key) => [key, before[key as keyof T]]), + ) as U; diff --git a/server/src/internal/billing/v2/utils/billingPlan/mergeAutumnBillingPlans.ts b/server/src/internal/billing/v2/utils/billingPlan/mergeAutumnBillingPlans.ts index 4c44bca6de..3752d423c9 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/mergeAutumnBillingPlans.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/mergeAutumnBillingPlans.ts @@ -1,4 +1,4 @@ -import type { AutumnBillingPlan } from "@autumn/shared"; +import { type AutumnBillingPlan, addSafe } from "@autumn/shared"; export const mergeAutumnBillingPlans = ({ base, @@ -85,13 +85,10 @@ export const mergeAutumnBillingPlans = ({ getKey: (pooledCustomerEntitlement) => pooledCustomerEntitlement.id, }) ?? [], - updatePoolBalances: - mergeByKey({ - base: base.pooledBalancePlan?.updatePoolBalances, - incoming: incoming.pooledBalancePlan?.updatePoolBalances, - getKey: (pooledCustomerEntitlement) => - pooledCustomerEntitlement.id, - }) ?? [], + updatePoolBalances: mergePooledBalanceUpdates({ + base: base.pooledBalancePlan?.updatePoolBalances, + incoming: incoming.pooledBalancePlan?.updatePoolBalances, + }), insertPoolContributions: mergeById({ base: base.pooledBalancePlan?.insertPoolContributions, @@ -162,6 +159,43 @@ const mergeByKey = ({ return Array.from(itemByKey.values()); }; +type PooledBalanceUpdate = NonNullable< + AutumnBillingPlan["pooledBalancePlan"] +>["updatePoolBalances"][number]; + +const mergePooledBalanceUpdates = ({ + base, + incoming, +}: { + base?: PooledBalanceUpdate[]; + incoming?: PooledBalanceUpdate[]; +}): PooledBalanceUpdate[] => { + const updatesByPoolId = new Map(); + + for (const update of [...(base ?? []), ...(incoming ?? [])]) { + const poolId = update.pooledCustomerEntitlement.id; + const existing = updatesByPoolId.get(poolId); + updatesByPoolId.set( + poolId, + existing + ? { + pooledCustomerEntitlement: update.pooledCustomerEntitlement, + balanceDelta: addSafe({ + left: existing.balanceDelta, + right: update.balanceDelta, + }), + grantedDelta: addSafe({ + left: existing.grantedDelta, + right: update.grantedDelta, + }), + } + : update, + ); + } + + return Array.from(updatesByPoolId.values()); +}; + type PatchCustomerProduct = NonNullable< AutumnBillingPlan["patchCustomerProducts"] >[number]; diff --git a/server/src/internal/billing/v2/utils/billingPlan/operationsOrUndefined.ts b/server/src/internal/billing/v2/utils/billingPlan/operationsOrUndefined.ts new file mode 100644 index 0000000000..9280e4ec71 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingPlan/operationsOrUndefined.ts @@ -0,0 +1,2 @@ +export const operationsOrUndefined = (operations: T[]) => + operations.length > 0 ? operations : undefined; diff --git a/server/src/internal/billing/v2/utils/billingPlan/pooledBalancePlan.ts b/server/src/internal/billing/v2/utils/billingPlan/pooledBalancePlan.ts index 2db613cacc..19a8bd4a2b 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/pooledBalancePlan.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/pooledBalancePlan.ts @@ -28,5 +28,7 @@ export const getChangedPooledBalances = ({ autumnBillingPlan: AutumnBillingPlan; }) => [ ...(autumnBillingPlan.pooledBalancePlan?.insertPoolBalances ?? []), - ...(autumnBillingPlan.pooledBalancePlan?.updatePoolBalances ?? []), + ...(autumnBillingPlan.pooledBalancePlan?.updatePoolBalances ?? []).map( + (update) => update.pooledCustomerEntitlement, + ), ]; diff --git a/server/src/internal/billing/v2/utils/lineItems/customerLicenseToLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/customerLicenseToLineItems.ts index b1f4564d14..b29d23beb2 100644 --- a/server/src/internal/billing/v2/utils/lineItems/customerLicenseToLineItems.ts +++ b/server/src/internal/billing/v2/utils/lineItems/customerLicenseToLineItems.ts @@ -50,8 +50,7 @@ export const customerLicenseToLineItems = ({ billingContext.customerLicenseBillingContext?.licenseBillingPriceRows ?? [] ).filter((row) => row.source.customerLicenseId === customerLicense.id); const projectedPlanLicenseIds = - billingContext.customerLicenseBillingContext?.projectedPlanLicenseIds ?? - new Set(); + billingContext.customerLicenseBillingContext?.projectedPlanLicenseIds ?? []; // Seats bill through THIS side's definition: refunds get the outgoing // pool's terms, charges the incoming — mirroring the repoint executor. diff --git a/server/src/internal/billing/v2/utils/lineItems/resolveLicenseBillingRowsThroughDefinition.ts b/server/src/internal/billing/v2/utils/lineItems/resolveLicenseBillingRowsThroughDefinition.ts index 9cde4ed316..e3fe2cd6bf 100644 --- a/server/src/internal/billing/v2/utils/lineItems/resolveLicenseBillingRowsThroughDefinition.ts +++ b/server/src/internal/billing/v2/utils/lineItems/resolveLicenseBillingRowsThroughDefinition.ts @@ -1,7 +1,4 @@ -import { - type FullPlanLicense, - type LicenseBillingPriceRow, -} from "@autumn/shared"; +import type { FullPlanLicense, LicenseBillingPriceRow } from "@autumn/shared"; /** Selects projected rows for this definition, otherwise persisted rows. */ export const resolveLicenseBillingRowsThroughDefinition = ({ @@ -11,11 +8,11 @@ export const resolveLicenseBillingRowsThroughDefinition = ({ }: { licenseBillingRows: LicenseBillingPriceRow[]; planLicense: FullPlanLicense; - projectedPlanLicenseIds: Set; + projectedPlanLicenseIds: string[]; }): LicenseBillingPriceRow[] => { const projectedRows = licenseBillingRows.filter( (row) => row.source.planLicenseId === planLicense.id, ); - if (projectedPlanLicenseIds.has(planLicense.id)) return projectedRows; + if (projectedPlanLicenseIds.includes(planLicense.id)) return projectedRows; return licenseBillingRows.filter((row) => !row.source.planLicenseId); }; diff --git a/server/src/internal/catalog/actions/catalogMappings/catalogMappingUtils.ts b/server/src/internal/catalog/actions/catalogMappings/catalogMappingUtils.ts index c06c8d6bf2..8b75a83f83 100644 --- a/server/src/internal/catalog/actions/catalogMappings/catalogMappingUtils.ts +++ b/server/src/internal/catalog/actions/catalogMappings/catalogMappingUtils.ts @@ -156,8 +156,8 @@ export const buildProductMappingContext = ({ currency: string; }): ProductMappingContext => { const rawItems = mapToProductItems({ - prices: product.prices, - entitlements: product.entitlements, + prices: product.prices ?? [], + entitlements: product.entitlements ?? [], features, }); const sortedItems = sortProductItems(rawItems, features); @@ -165,7 +165,7 @@ export const buildProductMappingContext = ({ product: { items: sortedItems } as any, }); const basePrice = basePriceItem?.price_id - ? product.prices.find((price) => price.id === basePriceItem.price_id) + ? (product.prices ?? []).find((price) => price.id === basePriceItem.price_id) : undefined; const featureItems = productV2ToFeatureItems({ @@ -175,7 +175,7 @@ export const buildProductMappingContext = ({ const itemPrices = featureItems .filter((item) => item.price_id && isFeaturePriceItem(item)) .map((item) => { - const price = product.prices.find((p) => p.id === item.price_id); + const price = (product.prices ?? []).find((p) => p.id === item.price_id); const apiItem = productItemsToPlanItemsV1({ items: [item], features, @@ -246,6 +246,6 @@ export const productHasStripeProductId = ({ stripeProductId: string; }) => product.processor?.id === stripeProductId || - product.prices.some( + (product.prices ?? []).some( (price) => price.config.stripe_product_id === stripeProductId, ); diff --git a/server/src/internal/catalog/actions/catalogPlanDependencies.ts b/server/src/internal/catalog/actions/catalogPlanDependencies.ts index 6853c51ece..1a218109d9 100644 --- a/server/src/internal/catalog/actions/catalogPlanDependencies.ts +++ b/server/src/internal/catalog/actions/catalogPlanDependencies.ts @@ -8,6 +8,8 @@ import { const referenceKey = (id: string, version?: number) => version === undefined ? id : `${id}@${version}`; +const targetId = (plan: CatalogPlanParams) => plan.new_plan_id ?? plan.plan_id; + const dependenciesForPlan = (plan: CatalogPlanParams) => (plan.licenses ?? []).map((license) => license.license_plan_id); @@ -16,8 +18,8 @@ export const sortCatalogPlansByDependencies = ( ): CatalogPlanParams[] => { const byReference = new Map(); for (const plan of plans) { - byReference.set(referenceKey(plan.plan_id, plan.version), plan); - const latestKey = plan.new_plan_id ?? plan.plan_id; + byReference.set(referenceKey(targetId(plan), plan.version), plan); + const latestKey = targetId(plan); const latest = byReference.get(latestKey); if (!latest || (plan.version ?? 0) > (latest.version ?? 0)) { byReference.set(latestKey, plan); @@ -28,7 +30,7 @@ export const sortCatalogPlansByDependencies = ( const visited = new Set(); const sorted: CatalogPlanParams[] = []; const visit = (plan: CatalogPlanParams) => { - const key = referenceKey(plan.plan_id, plan.version); + const key = referenceKey(targetId(plan), plan.version); if (visiting.has(key)) { throw new RecaseError({ message: "Plan dependency cycle detected.", @@ -40,7 +42,7 @@ export const sortCatalogPlansByDependencies = ( visiting.add(key); if (plan.version !== undefined && plan.version > 1) { const previous = byReference.get( - referenceKey(plan.plan_id, plan.version - 1), + referenceKey(targetId(plan), plan.version - 1), ); if (previous) visit(previous); } @@ -75,21 +77,22 @@ export const validateCatalogPlanVersionTargets = ({ } for (const plan of sortCatalogPlansByDependencies(plans)) { + const id = targetId(plan); if ( plan.version === undefined || - existing.has(referenceKey(plan.plan_id, plan.version)) + existing.has(referenceKey(id, plan.version)) ) { continue; } - const expected = (latestById.get(plan.plan_id) ?? 0) + 1; + const expected = (latestById.get(id) ?? 0) + 1; if (plan.version !== expected) { throw new RecaseError({ - message: `Plan ${plan.plan_id} version must be ${expected}, received ${plan.version}.`, + message: `Plan ${id} version must be ${expected}, received ${plan.version}.`, code: ErrCode.InvalidRequest, statusCode: 400, }); } - existing.add(referenceKey(plan.plan_id, plan.version)); - latestById.set(plan.plan_id, plan.version); + existing.add(referenceKey(id, plan.version)); + latestById.set(id, plan.version); } }; diff --git a/server/src/internal/catalog/actions/catalogPlanPreflight.ts b/server/src/internal/catalog/actions/catalogPlanPreflight.ts index 3411310f97..45f4ad54e3 100644 --- a/server/src/internal/catalog/actions/catalogPlanPreflight.ts +++ b/server/src/internal/catalog/actions/catalogPlanPreflight.ts @@ -33,6 +33,8 @@ const virtualProduct = ({ ...(current ?? {}), id, version, + prices: current?.prices ?? [], + entitlements: current?.entitlements ?? [], archived: archived ?? current?.archived ?? false, internal_id: current?.version === version diff --git a/server/src/internal/catalog/actions/previewUpdateCatalog/previewUpdateCatalog.ts b/server/src/internal/catalog/actions/previewUpdateCatalog/previewUpdateCatalog.ts index 221b1c3606..74e61e95bf 100644 --- a/server/src/internal/catalog/actions/previewUpdateCatalog/previewUpdateCatalog.ts +++ b/server/src/internal/catalog/actions/previewUpdateCatalog/previewUpdateCatalog.ts @@ -42,9 +42,9 @@ const productUsesFeature = ({ product: FullProduct; featureId: string; }) => - product.entitlements.some( + (product.entitlements ?? []).some( (entitlement) => entitlement.feature.id === featureId, - ) || product.prices.some((price) => price.config?.feature_id === featureId); + ) || (product.prices ?? []).some((price) => price.config?.feature_id === featureId); const productsForFeatureRemovalPreview = ({ featureId, diff --git a/server/src/internal/customers/CusSearchService.ts b/server/src/internal/customers/CusSearchService.ts index f4a1656e22..9e342482ab 100644 --- a/server/src/internal/customers/CusSearchService.ts +++ b/server/src/internal/customers/CusSearchService.ts @@ -64,26 +64,45 @@ const productFields = { is_add_on: products.is_add_on, }; -const dashboardProductFilterToDrizzleSql = ( - filter: DashboardProductVersionFilter, -) => +const dashboardProductFilterToDrizzleSql = ({ + filter, + orgId, + env, +}: { + filter: DashboardProductVersionFilter; + orgId: string; + env: AppEnv; +}) => and( isCustomDashboardProductFilter(filter) ? and( eq(customerProducts.product_id, filter.productId), eq(customerProducts.is_custom, true), ) - : and(eq(products.id, filter.productId), eq(products.version, filter.version)), + : and( + eq(products.org_id, orgId), + eq(products.env, env), + eq(products.id, filter.productId), + eq(products.version, filter.version), + ), ); -const dashboardProductFilterToRawSql = ( - filter: DashboardProductVersionFilter, -) => +const dashboardProductFilterToRawSql = ({ + filter, + orgId, + env, +}: { + filter: DashboardProductVersionFilter; + orgId: string; + env: AppEnv; +}) => isCustomDashboardProductFilter(filter) ? sql`(${customerProducts.product_id} = ${filter.productId} AND ${customerProducts.is_custom} = true)` - : sql`(${products.id} = ${filter.productId} AND ${products.version} = ${filter.version})`; + : sql`(${products.org_id} = ${orgId} AND ${products.env} = ${env} AND ${products.id} = ${filter.productId} AND ${products.version} = ${filter.version})`; -const dashboardIntervalFilterToRawSql = (intervals: DashboardIntervalFilter[]) => +const dashboardIntervalFilterToRawSql = ( + intervals: DashboardIntervalFilter[], +) => sql`EXISTS ( SELECT 1 FROM customer_prices cpr_interval @@ -195,7 +214,9 @@ export class CusSearchService { // New product:version filtering productVersionFilters.length > 0 ? or( - ...productVersionFilters.map(dashboardProductFilterToDrizzleSql), + ...productVersionFilters.map((filter) => + dashboardProductFilterToDrizzleSql({ filter, orgId, env }), + ), ) : undefined, // Legacy product filtering (fallback) @@ -1032,7 +1053,9 @@ const buildSearchPredicates = ({ const versionRaw = productVersionFilters.length > 0 ? sql`(${sql.join( - productVersionFilters.map(dashboardProductFilterToRawSql), + productVersionFilters.map((filter) => + dashboardProductFilterToRawSql({ filter, orgId, env }), + ), sql` OR `, )})` : null; @@ -1068,7 +1091,9 @@ const buildSearchPredicates = ({ const filtersDrizzle = and( productVersionFilters.length > 0 ? or( - ...productVersionFilters.map(dashboardProductFilterToDrizzleSql), + ...productVersionFilters.map((filter) => + dashboardProductFilterToDrizzleSql({ filter, orgId, env }), + ), ) : undefined, intervalFilters.length > 0 diff --git a/server/src/internal/customers/actions/createWithDefaults/createCustomerContext.ts b/server/src/internal/customers/actions/createWithDefaults/createCustomerContext.ts index ced93146f6..e648b025a7 100644 --- a/server/src/internal/customers/actions/createWithDefaults/createCustomerContext.ts +++ b/server/src/internal/customers/actions/createWithDefaults/createCustomerContext.ts @@ -1,4 +1,5 @@ import type { + AutoTopupParams, BillingContext, FullCustomer, FullProduct, @@ -12,6 +13,7 @@ export interface CreateCustomerContextFree { trialContext?: TrialContext; hasPaidProducts: boolean; billingContext?: BillingContext; + autoTopups?: AutoTopupParams[]; } export type CreateCustomerContext = CreateCustomerContextFree; diff --git a/server/src/internal/customers/actions/createWithDefaults/createCustomerWithDefaults.ts b/server/src/internal/customers/actions/createWithDefaults/createCustomerWithDefaults.ts index 73a6ba893b..592c64f21a 100644 --- a/server/src/internal/customers/actions/createWithDefaults/createCustomerWithDefaults.ts +++ b/server/src/internal/customers/actions/createWithDefaults/createCustomerWithDefaults.ts @@ -9,6 +9,7 @@ import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan.js"; import { sendBillingUpdatedWebhook } from "@/internal/billing/v2/workflows/sendBillingUpdatedWebhook/sendBillingUpdatedWebhook.js"; import { billingPlanToSendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.js"; +import { setCustomerCreationRecoveryStage } from "@/internal/customers/recovery/customerCreationRecoveryStage.js"; import { computeCreateCustomerPlan } from "./compute/computeCreateCustomerPlan.js"; import { executeAutumnCreateCustomerPlan } from "./execute/executeAutumnCreateCustomerPlan.js"; import { finalizeCreateCustomer } from "./finalizeCreateCustomer.js"; @@ -43,6 +44,8 @@ export const createCustomerWithDefaults = async ({ customerId: string | null; customerData?: CustomerData; }): Promise => { + setCustomerCreationRecoveryStage({ ctx, stage: "pre_commit" }); + // ============ Phase 1: Create Autumn customer ============ // 1. Setup @@ -67,7 +70,10 @@ export const createCustomerWithDefaults = async ({ logAutumnPlanResult({ ctx, result: autumnResult }); // Early return if customer already existed or no paid products - if (autumnResult.type === "existing") return context.fullCustomer; + if (autumnResult.type === "existing") { + setCustomerCreationRecoveryStage({ ctx, stage: "completed" }); + return context.fullCustomer; + } // ============ Phase 2: Create stripe customer / attach paid defaults ============ @@ -94,14 +100,20 @@ export const createCustomerWithDefaults = async ({ customerProducts: context.fullCustomer.customer_products, }); - if (!shouldCreateStripeCustomer) return context.fullCustomer; + if (!shouldCreateStripeCustomer) { + setCustomerCreationRecoveryStage({ ctx, stage: "completed" }); + return context.fullCustomer; + } const billingContext = await setupCreateCustomerBillingContext({ ctx, context, }); - if (!shouldAttachPaidDefaults) return context.fullCustomer; + if (!shouldAttachPaidDefaults) { + setCustomerCreationRecoveryStage({ ctx, stage: "completed" }); + return context.fullCustomer; + } // 5. Evaluate Stripe billing plan const stripeBillingPlan = await evaluateStripeBillingPlan({ @@ -120,12 +132,14 @@ export const createCustomerWithDefaults = async ({ }); // 7. Finalize (link subscription back to Autumn) - return await finalizeCreateCustomer({ + const fullCustomer = await finalizeCreateCustomer({ ctx, context, autumnBillingPlan, stripeSubscription, }); + setCustomerCreationRecoveryStage({ ctx, stage: "completed" }); + return fullCustomer; } finally { await billingPlanToSendProductsUpdated({ ctx, diff --git a/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts b/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts index 240d49a9b8..2c1b6721d1 100644 --- a/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts +++ b/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts @@ -5,6 +5,8 @@ import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan.js"; import type { CreateCustomerContext } from "@/internal/customers/actions/createWithDefaults/createCustomerContext.js"; +import { syncAutoTopupPurchaseLimitCounts } from "@/internal/customers/actions/update/syncAutoTopupPurchaseLimitCounts.js"; +import { setCustomerCreationRecoveryStage } from "@/internal/customers/recovery/customerCreationRecoveryStage.js"; import { captureOrgEvent } from "@/utils/posthog.js"; import { CusService } from "../../../CusService.js"; @@ -51,6 +53,12 @@ export const executeAutumnCreateCustomerPlan = async ({ return; } + await syncAutoTopupPurchaseLimitCounts({ + ctx: { ...ctx, db: txDb }, + customer: fullCustomer, + autoTopups: context.autoTopups ?? [], + }); + await executeAutumnBillingPlan({ ctx: { ...ctx, db: txDb }, autumnBillingPlan, @@ -63,6 +71,7 @@ export const executeAutumnCreateCustomerPlan = async ({ logger.info( `Customer already exists, returning existing: ${fullCustomer.id || fullCustomer.email}`, ); + setCustomerCreationRecoveryStage({ ctx, stage: "existing" }); const existingCustomer = await CusService.getFull({ ctx, idOrInternalId: fullCustomer.id || fullCustomer.internal_id, @@ -80,6 +89,7 @@ export const executeAutumnCreateCustomerPlan = async ({ logger.info( `Customer already exists (claimed or existing): ${fullCustomer.id || fullCustomer.internal_id}`, ); + setCustomerCreationRecoveryStage({ ctx, stage: "existing" }); const existingCustomer = await CusService.getFull({ ctx, idOrInternalId: fullCustomer.internal_id, @@ -91,6 +101,8 @@ export const executeAutumnCreateCustomerPlan = async ({ return { type: "existing" }; } + setCustomerCreationRecoveryStage({ ctx, stage: "autumn_committed" }); + if (ctx.authType === AuthType.SecretKey) { await captureOrgEvent({ orgId: ctx.org.id, diff --git a/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomer.ts b/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomer.ts index a751b3b8a7..0014d22f88 100644 --- a/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomer.ts +++ b/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomer.ts @@ -52,5 +52,6 @@ export const setupCreateCustomer = async ({ currentEpochMs, trialContext, hasPaidProducts, + autoTopups: customerData?.billing_controls?.auto_topups, }; }; diff --git a/server/src/internal/customers/actions/getOrCreateApiCustomerByRollout.ts b/server/src/internal/customers/actions/getOrCreateApiCustomerByRollout.ts index a6021a3572..4fddc54791 100644 --- a/server/src/internal/customers/actions/getOrCreateApiCustomerByRollout.ts +++ b/server/src/internal/customers/actions/getOrCreateApiCustomerByRollout.ts @@ -2,6 +2,11 @@ import type { CheckParams, TrackParams } from "@autumn/shared"; import { shed503OnTransientError } from "@/db/shed503OnTransientError.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { getOrCreateCachedFullSubject } from "@/internal/customers/cache/fullSubject/index.js"; +import { + getCustomerCreationRecoveryStage, + setCustomerCreationRecoveryStage, +} from "@/internal/customers/recovery/customerCreationRecoveryStage.js"; +import { queueFailedCustomerCreation } from "@/internal/customers/recovery/queueFailedCustomerCreation.js"; import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js"; import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js"; import { getOrCreateCachedFullCustomer } from "../cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js"; @@ -13,6 +18,7 @@ export const getOrCreateApiCustomerByRollout = async ({ params, source, withAutumnId, + enqueueRecoveryOnTransientFailure = true, }: { ctx: AutumnContext; params: Omit & { @@ -20,7 +26,10 @@ export const getOrCreateApiCustomerByRollout = async ({ }; source?: string; withAutumnId?: boolean; + enqueueRecoveryOnTransientFailure?: boolean; }) => { + setCustomerCreationRecoveryStage({ ctx, stage: "lookup" }); + let fullSubject: | Awaited> | undefined; @@ -33,6 +42,17 @@ export const getOrCreateApiCustomerByRollout = async ({ ctx, source: "get_or_create", run: () => getOrCreateCachedFullSubject({ ctx, params, source }), + onTransientError: enqueueRecoveryOnTransientFailure + ? async () => { + await queueFailedCustomerCreation({ + ctx, + params, + source, + withAutumnId, + failureStage: getCustomerCreationRecoveryStage({ ctx }), + }); + } + : undefined, }); } else { fullCustomer = await getOrCreateCachedFullCustomer({ diff --git a/server/src/internal/customers/actions/resetCustomerEntitlements/batchResetCustomerEntitlements.ts b/server/src/internal/customers/actions/resetCustomerEntitlements/batchResetCustomerEntitlements.ts index dc349b6803..eddd506bc8 100644 --- a/server/src/internal/customers/actions/resetCustomerEntitlements/batchResetCustomerEntitlements.ts +++ b/server/src/internal/customers/actions/resetCustomerEntitlements/batchResetCustomerEntitlements.ts @@ -1,10 +1,14 @@ import { CusProductStatus } from "@autumn/shared"; +import pLimit from "p-limit"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { BatchResetCusEntsPayload } from "@/queue/workflows.js"; // import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js"; import { CusService } from "../../CusService.js"; import { getFullSubject } from "../../repos/getFullSubject/getFullSubject.js"; +const CUSTOMER_REHYDRATION_CONCURRENCY = 5; +const BATCH_SIZE = 100; + /** * SQS worker handler: rehydrates each subject, which triggers lazy reset internally. */ @@ -19,38 +23,40 @@ export const batchResetCustomerEntitlements = async ({ if (resets.length === 0) return; - const BATCH_SIZE = 100; + const limit = pLimit(CUSTOMER_REHYDRATION_CONCURRENCY); for (let i = 0; i < resets.length; i += BATCH_SIZE) { const batch = resets.slice(i, i + BATCH_SIZE); await Promise.all( - batch.map(async (reset) => { - if (reset.internalEntityId || reset.entityId) { - await getFullSubject({ + batch.map((reset) => + limit(async () => { + if (reset.internalEntityId || reset.entityId) { + await getFullSubject({ + ctx, + customerId: reset.internalCustomerId, + entityId: reset.internalEntityId ?? reset.entityId, + inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + }); + return; + } + + await CusService.getFull({ ctx, - customerId: reset.internalCustomerId, - entityId: reset.internalEntityId ?? reset.entityId, + idOrInternalId: reset.internalCustomerId, inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], }); - return; - } - - await CusService.getFull({ - ctx, - idOrInternalId: reset.internalCustomerId, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - }); - - // V2 subject cache path: triggers lazyResetSubjectEntitlements - // if (isFullSubjectRolloutEnabled({ ctx })) { - // await getFullSubject({ - // ctx, - // customerId: reset.internalCustomerId, - // inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - // }); - // } - }), + + // V2 subject cache path: triggers lazyResetSubjectEntitlements + // if (isFullSubjectRolloutEnabled({ ctx })) { + // await getFullSubject({ + // ctx, + // customerId: reset.internalCustomerId, + // inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + // }); + // } + }), + ), ); } }; diff --git a/server/src/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.ts b/server/src/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.ts index a1c40aaca6..6230744f6e 100644 --- a/server/src/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.ts +++ b/server/src/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.ts @@ -63,8 +63,8 @@ export const lazyResetSubjectEntitlements = async ({ for (const customerEntitlement of customerEntitlementsNeedingReset) { const result = await processReset({ - cusEnt: customerEntitlement, ctx, + cusEnt: customerEntitlement, }); if (!result) continue; computed.push({ diff --git a/server/src/internal/customers/actions/update/syncAutoTopupPurchaseLimitCounts.ts b/server/src/internal/customers/actions/update/syncAutoTopupPurchaseLimitCounts.ts new file mode 100644 index 0000000000..a57ff715ac --- /dev/null +++ b/server/src/internal/customers/actions/update/syncAutoTopupPurchaseLimitCounts.ts @@ -0,0 +1,117 @@ +import { + type AutoTopup, + type AutoTopupParams, + type Customer, + ErrCode, + RecaseError, + stripAutoTopupCountsForStorage, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { normalizeWindowCounter } from "@/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.js"; +import { getOrCreateAutoTopupLimitState } from "@/internal/balances/autoTopUp/helpers/limits/getOrCreateAutoTopupLimitState.js"; +import { autoTopupLimitRepo } from "@/internal/balances/autoTopUp/repos"; + +export const validateAutoTopupPurchaseLimitCounts = ({ + autoTopups, +}: { + autoTopups: AutoTopupParams[]; +}) => { + for (const topup of autoTopups) { + const purchaseLimit = topup.purchase_limit; + const count = purchaseLimit?.count; + if (purchaseLimit == null || count === undefined) continue; + + if (count > purchaseLimit.limit) { + throw new RecaseError({ + message: `purchase_limit.count (${count}) cannot exceed purchase_limit.limit (${purchaseLimit.limit}) for feature ${topup.feature_id}`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + } +}; + +/** + * When customers.update includes `purchase_limit.count`, sync that value into + * `auto_topup_limit_states` and return auto_topups safe for JSONB storage + * (runtime `count` stripped). + * + * Omit `count` → leave the limit-state row unchanged. + * `count > limit` → 400. + * Stale/equal window → project a future `purchase_window_ends_at` so expand / + * preflight do not immediately normalize the written count back to 0. + * Active window → preserve `purchase_window_ends_at`. + */ +export const syncAutoTopupPurchaseLimitCounts = async ({ + ctx, + customer, + autoTopups, +}: { + ctx: AutumnContext; + customer: Customer; + autoTopups: AutoTopupParams[]; +}): Promise => { + validateAutoTopupPurchaseLimitCounts({ autoTopups }); + + const now = Date.now(); + const customerId = customer.id || customer.internal_id; + + for (const topup of autoTopups) { + const purchaseLimit = topup.purchase_limit; + const count = purchaseLimit?.count; + if (purchaseLimit == null || count === undefined) continue; + + const state = await getOrCreateAutoTopupLimitState({ + ctx, + internalCustomerId: customer.internal_id, + customerId, + featureId: topup.feature_id, + now, + }); + + const windowConfig = { + interval: purchaseLimit.interval, + interval_count: purchaseLimit.interval_count ?? 1, + limit: purchaseLimit.limit, + }; + + const previousPurchaseLimit = customer.auto_topups?.find( + (existingTopup) => existingTopup.feature_id === topup.feature_id, + )?.purchase_limit; + const intervalChanged = + previousPurchaseLimit?.interval !== purchaseLimit.interval || + (previousPurchaseLimit?.interval_count ?? 1) !== + (purchaseLimit.interval_count ?? 1); + + let purchaseWindowEndsAt = state.purchase_window_ends_at; + if (intervalChanged || now >= purchaseWindowEndsAt) { + const normalized = normalizeWindowCounter({ + now, + windowEndsAt: intervalChanged ? now : purchaseWindowEndsAt, + count: 0, + windowConfig, + from: intervalChanged ? now : purchaseWindowEndsAt, + }); + if (!normalized) { + throw new RecaseError({ + message: `Failed to initialize purchase limit window for feature ${topup.feature_id}`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + purchaseWindowEndsAt = normalized.windowEndsAt; + } + + await autoTopupLimitRepo.updateById({ + ctx, + id: state.id, + updates: { + purchase_count: count, + purchase_window_ends_at: purchaseWindowEndsAt, + updated_at: now, + }, + }); + } + + return stripAutoTopupCountsForStorage(autoTopups) ?? []; +}; diff --git a/server/src/internal/customers/actions/update/updateCustomer.ts b/server/src/internal/customers/actions/update/updateCustomer.ts index 85137e5ebc..52f4e1159e 100644 --- a/server/src/internal/customers/actions/update/updateCustomer.ts +++ b/server/src/internal/customers/actions/update/updateCustomer.ts @@ -6,9 +6,11 @@ import { ProcessorType, RecaseError, shouldForwardCustomerMetadata, + stripAutoTopupCountsForStorage, type UpdateCustomerParamsV1, } from "@autumn/shared"; import type Stripe from "stripe"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli"; import { autumnToStripeCustomerMetadata, @@ -18,6 +20,10 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { triggerAutoTopUpsOnEnabled } from "@/internal/balances/autoTopUp/triggerAutoTopUpsOnEnabled"; import { CusService } from "@/internal/customers/CusService"; import { getApiCustomerByRollout } from "../getApiCustomerByRollout"; +import { + syncAutoTopupPurchaseLimitCounts, + validateAutoTopupPurchaseLimitCounts, +} from "./syncAutoTopupPurchaseLimitCounts"; export const updateCustomer = async ({ ctx, @@ -49,6 +55,10 @@ export const updateCustomer = async ({ throw new CustomerNotFoundError({ customerId }); } + validateAutoTopupPurchaseLimitCounts({ + autoTopups: billing_controls?.auto_topups ?? [], + }); + if (newCustomerId === null) { throw new RecaseError({ message: "Customer ID cannot be set to null", @@ -136,8 +146,10 @@ export const updateCustomer = async ({ // Prepare update data — only include defined billing control fields const billingControlUpdates: Partial = {}; if (billing_controls) { - if (billing_controls.auto_topups !== undefined) - billingControlUpdates.auto_topups = billing_controls.auto_topups; + if (billing_controls.auto_topups !== undefined) { + billingControlUpdates.auto_topups = + stripAutoTopupCountsForStorage(billing_controls.auto_topups) ?? []; + } if (billing_controls.spend_limits !== undefined) billingControlUpdates.spend_limits = billing_controls.spend_limits; if (billing_controls.usage_limits !== undefined) @@ -181,10 +193,22 @@ export const updateCustomer = async ({ delete updateData.id; } - await CusService.update({ - ctx, - idOrInternalId: originalCustomer.id || originalCustomer.internal_id, - update: updateData, + await db.transaction(async (tx) => { + const txCtx = { ...ctx, db: tx as unknown as DrizzleCli }; + + if (billing_controls?.auto_topups !== undefined) { + await syncAutoTopupPurchaseLimitCounts({ + ctx: txCtx, + customer: originalCustomer, + autoTopups: billing_controls.auto_topups, + }); + } + + await CusService.update({ + ctx: txCtx, + idOrInternalId: originalCustomer.id || originalCustomer.internal_id, + update: updateData, + }); }); ctx.skipCache = true; diff --git a/server/src/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCustomerEntitlementBalances.ts b/server/src/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCustomerEntitlementBalances.ts new file mode 100644 index 0000000000..435788271f --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCustomerEntitlementBalances.ts @@ -0,0 +1,51 @@ +import type { Redis } from "ioredis"; +import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import { buildSharedFullSubjectBalanceKey } from "../../builders/buildSharedFullSubjectBalanceKey.js"; +import { AGGREGATED_BALANCE_FIELD } from "../../config/fullSubjectCacheConfig.js"; + +export type CustomerEntitlementBalanceInvalidation = { + orgId: string; + env: string; + customerId: string; + featureId: string; + customerEntitlementId: string; +}; + +/** + * Pipelined variant of invalidateCustomerEntitlementBalance: one HDEL per + * entry (cusEnt field + aggregated field), executed in a single round trip. + * Callers must group entries per routed redis client — and keep each call + * org-scoped so a cluster pipeline stays within one key slot (same rule as + * batchDeleteCachedFullCustomers). + */ +export const batchInvalidateCustomerEntitlementBalances = async ({ + redisV2, + invalidations, +}: { + redisV2: Redis; + invalidations: CustomerEntitlementBalanceInvalidation[]; +}): Promise => { + if (redisV2.status !== "ready") return; + + const pipeline = redisV2.pipeline(); + let queued = 0; + + for (const invalidation of invalidations) { + const { orgId, env, customerId, featureId, customerEntitlementId } = + invalidation; + if (!orgId || !env || !customerId || !featureId || !customerEntitlementId) { + continue; + } + + pipeline.hdel( + buildSharedFullSubjectBalanceKey({ orgId, env, customerId, featureId }), + customerEntitlementId, + AGGREGATED_BALANCE_FIELD, + ); + queued++; + } + + if (queued === 0) return; + + await tryRedisWrite(() => pipeline.exec(), redisV2); +}; diff --git a/server/src/internal/customers/cursorPaginatedFullCusQuery.ts b/server/src/internal/customers/cursorPaginatedFullCusQuery.ts index d5550c8256..3c0eafdd86 100644 --- a/server/src/internal/customers/cursorPaginatedFullCusQuery.ts +++ b/server/src/internal/customers/cursorPaginatedFullCusQuery.ts @@ -224,7 +224,8 @@ export const getCursorPaginatedFullCusQuery = ({ JOIN customer_entitlements ce ON ce.customer_product_id = cps_ranked.id LEFT JOIN pooled_balance_contributions pbc ON pbc.id = ce.pooled_contribution_id - AND cps_ranked.internal_entity_id IS NULL + AND pbc.source_customer_entitlement_id = ce.id + AND pbc.source_customer_product_id = cps_ranked.id ), ces_loose AS MATERIALIZED ( SELECT ce.id, ce.entitlement_id, row_to_json(ce) AS row_json diff --git a/server/src/internal/customers/cusProducts/actions/revertTrialExpiry.ts b/server/src/internal/customers/cusProducts/actions/revertTrialExpiry.ts index e972997b71..41c6201f18 100644 --- a/server/src/internal/customers/cusProducts/actions/revertTrialExpiry.ts +++ b/server/src/internal/customers/cusProducts/actions/revertTrialExpiry.ts @@ -1,12 +1,14 @@ import { type AutumnBillingPlan, CusProductStatus, - customerProducts as customerProductsTable, type customerProducts, + customerProducts as customerProductsTable, + findCustomerProductById, } from "@autumn/shared"; import { and, eq, type InferSelectModel } from "drizzle-orm"; import type { DrizzleCli } from "@/db/initDrizzle"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { applyPooledBalanceCustomerProductTransitions } from "@/internal/billing/v2/pooledBalances/execute/applyPooledBalanceCustomerProductTransitions"; import { sendBillingUpdatedWebhook } from "@/internal/billing/v2/workflows/sendBillingUpdatedWebhook/sendBillingUpdatedWebhook"; import { CusService } from "@/internal/customers/CusService"; import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService"; @@ -53,32 +55,57 @@ export const tryProcessRevertExpiry = async ({ inStatuses: [...RELEVANT_STATUSES, CusProductStatus.Paused], }); - const trialFullCusProduct = fullCustomer.customer_products.find( - (cp) => cp.id === customerProduct.id, - ); - const previousFullCusProduct = fullCustomer.customer_products.find( - (cp) => cp.id === previousCusProductId, - ); + const trialFullCusProduct = findCustomerProductById({ + fullCustomer, + customerProductId: customerProduct.id, + }); + const previousFullCusProduct = findCustomerProductById({ + fullCustomer, + customerProductId: previousCusProductId, + }); const now = Date.now(); - await ctx.db.transaction(async (tx) => { - const txDb = tx as unknown as DrizzleCli; + const restoredPreviousCustomerProduct = await ctx.db.transaction( + async (tx) => { + const txDb = tx as unknown as DrizzleCli; - await txDb - .update(customerProductsTable) - .set({ status: CusProductStatus.Expired, updated_at: now }) - .where(eq(customerProductsTable.id, customerProduct.id)); + await txDb + .update(customerProductsTable) + .set({ status: CusProductStatus.Expired, updated_at: now }) + .where(eq(customerProductsTable.id, customerProduct.id)); - await txDb - .update(customerProductsTable) - .set({ status: CusProductStatus.Active, updated_at: now }) - .where( - and( - eq(customerProductsTable.id, previousCusProductId), - eq(customerProductsTable.status, CusProductStatus.Paused), - ), - ); - }); + const restoredRows = await txDb + .update(customerProductsTable) + .set({ status: CusProductStatus.Active, updated_at: now }) + .where( + and( + eq(customerProductsTable.id, previousCusProductId), + eq(customerProductsTable.status, CusProductStatus.Paused), + ), + ) + .returning({ id: customerProductsTable.id }); + + return restoredRows.length > 0; + }, + ); + + if (trialFullCusProduct) { + await applyPooledBalanceCustomerProductTransitions({ + ctx, + fullCustomer, + outgoingCustomerProducts: [trialFullCusProduct], + incomingCustomerProducts: + restoredPreviousCustomerProduct && previousFullCusProduct + ? [ + { + ...previousFullCusProduct, + status: CusProductStatus.Active, + }, + ] + : [], + now, + }); + } await deleteCachedFullCustomer({ ctx, diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts index 2ac0843423..adad29eff0 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts @@ -3,7 +3,7 @@ import { type Rollover, rollovers, } from "@autumn/shared"; -import { and, eq, gt, inArray, isNull, or } from "drizzle-orm"; +import { and, eq, gt, inArray, isNull, or, type SQL, sql } from "drizzle-orm"; import type { CronContext } from "@/cron/utils/CronContext.js"; import { buildConflictUpdateColumns } from "@/db/dbUtils.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; @@ -31,17 +31,33 @@ export class RolloverService { return data; } - static async upsert({ db, rows }: { db: DrizzleCli; rows: Rollover[] }) { + static async upsert({ + db, + rows, + queryTag, + }: { + db: DrizzleCli; + rows: Rollover[]; + queryTag?: SQL; + }) { if (Array.isArray(rows) && rows.length === 0) return; const updateColumns = buildConflictUpdateColumns(rollovers, ["id"]); - await db + const query = db .insert(rollovers) .values(rows as any) .onConflictDoUpdate({ target: rollovers.id, set: updateColumns, }); + + if (queryTag) { + // getSQL() splices verbatim — embedding the builder itself would + // parenthesize it, and `(INSERT ...)` is invalid SQL. + await db.execute(sql`${query.getSQL()} ${queryTag}`); + } else { + await query; + } } // static async bulkUpdate({ db, rows }: { db: DrizzleCli; rows: Rollover[] }) { @@ -150,8 +166,24 @@ export class RolloverService { }; } - static async delete({ db, ids }: { db: DrizzleCli; ids: string[] }) { + static async delete({ + db, + ids, + queryTag, + }: { + db: DrizzleCli; + ids: string[]; + queryTag?: SQL; + }) { if (ids.length === 0) return; - const data = await db.delete(rollovers).where(inArray(rollovers.id, ids)); + const query = db.delete(rollovers).where(inArray(rollovers.id, ids)); + + if (queryTag) { + // getSQL() splices verbatim — embedding the builder itself would + // parenthesize it, and `(DELETE ...)` is invalid SQL. + await db.execute(sql`${query.getSQL()} ${queryTag}`); + } else { + await query; + } } } diff --git a/server/src/internal/customers/cusProducts/cusEnts/repos/countResetEligibleCustomerEntitlements.ts b/server/src/internal/customers/cusProducts/cusEnts/repos/countResetEligibleCustomerEntitlements.ts new file mode 100644 index 0000000000..aff5360e67 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/repos/countResetEligibleCustomerEntitlements.ts @@ -0,0 +1,48 @@ +import { customerEntitlements } from "@autumn/shared"; +import { sql } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { resetCronQueryTag } from "@/internal/balances/batchReset/resetCronQueryTag.js"; +import { resetEligibleFilterSql } from "./getResetEligibleCustomerEntitlementsPage.js"; + +/** + * Capped count of customer entitlements the reset scan would currently pick + * up — the exact same eligibility predicate as the scan page query. The + * inner LIMIT bounds the index walk so the count stays cheap against a large + * backlog. + * + * Exported separately so experiments can EXPLAIN the exact query. + */ +export const buildCountResetEligibleQuery = ({ + dueBefore, + cap, +}: { + dueBefore: number; + cap: number; +}) => sql` + SELECT count(*)::int AS count + FROM ( + SELECT 1 + FROM ${customerEntitlements} + WHERE ${resetEligibleFilterSql({ dueBefore })} + LIMIT ${cap} + ) eligible + ${resetCronQueryTag("countEligible")} +`; + +/** A capped result means "at least this many". */ +export const countResetEligibleCustomerEntitlements = async ({ + db, + dueBefore, + cap, +}: { + db: DrizzleCli; + dueBefore: number; + cap: number; +}): Promise<{ count: number; capped: boolean }> => { + const rows = await db.execute<{ count: number }>( + buildCountResetEligibleQuery({ dueBefore, cap }), + ); + + const count = rows[0]?.count ?? 0; + return { count, capped: count >= cap }; +}; diff --git a/server/src/internal/customers/cusProducts/cusEnts/repos/getResetContextByIds.ts b/server/src/internal/customers/cusProducts/cusEnts/repos/getResetContextByIds.ts new file mode 100644 index 0000000000..321f7e1e20 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/repos/getResetContextByIds.ts @@ -0,0 +1,129 @@ +import type { Customer, FullCusEntWithFullCusProduct } from "@autumn/shared"; +import { RELEVANT_STATUSES } from "@autumn/shared"; +import { sql } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { resetCronQueryTag } from "@/internal/balances/batchReset/resetCronQueryTag.js"; + +/** + * A customer entitlement hydrated with everything batch reset needs: + * the FullCusEntWithFullCusProduct shape processReset consumes, plus the + * owning customer for org/env checks, cache invalidation and redis routing. + * + * Hydration is intentionally UNVALIDATED (same trust in the DB shape as the + * V1 cron and lazy-reset paths): legacy quirks like an entity balance keyed + * "null" with a null id must still reset — a validation reject would leave + * such rows permanently unreset at the head of the scan. + */ +export type ResetContextCustomerEntitlement = FullCusEntWithFullCusProduct & { + customer: Customer; + // Reset-scan denormalization flag; not yet part of the shared API model. + expired?: boolean | null; +}; + +/** + * Single-statement hydration query for an explicit set of customer entitlement + * IDs. The full reset-context object is assembled in SQL as jsonb, shaped + * exactly like ResetContextCustomerEntitlementSchema: + * - parent_cp lateral: seat rows (license assignments) inherit + * status/subscription_ids from their pool's LIVE parent product; a NULL + * link matches nothing, and predecessors lingering on expired parents + * lose to live ones via the ORDER BY. + * - prices_agg lateral: customer_prices with nested price per product + * (price-backed detection + prepaid starting balance). + * - rollovers_agg lateral: only evaluated when the entitlement has a + * rollover config (needed for post-reset max-cap clearing). + * + * Sibling customer_entitlements on the product are intentionally left empty. + * Exported separately so experiments can EXPLAIN the exact worker query. + */ +export const buildResetContextByIdsQuery = ({ + customerEntitlementIds, +}: { + customerEntitlementIds: string[]; +}) => sql` + SELECT + ce.id, + to_jsonb(ce) + || jsonb_build_object( + 'entitlement', to_jsonb(e) || jsonb_build_object('feature', to_jsonb(f)), + 'replaceables', '[]'::jsonb, + 'rollovers', COALESCE(rollovers_agg.rollovers, '[]'::jsonb), + 'customer', to_jsonb(c), + 'pooled_balance', to_jsonb(pb), + 'customer_product', CASE + WHEN cp.id IS NULL THEN NULL + ELSE to_jsonb(cp) || jsonb_build_object( + 'status', COALESCE(parent_cp.parent_status, cp.status), + 'subscription_ids', COALESCE(parent_cp.parent_subscription_ids, cp.subscription_ids), + 'customer_prices', COALESCE(prices_agg.customer_prices, '[]'::jsonb), + 'customer_entitlements', '[]'::jsonb, + 'product', to_jsonb(p) + ) + END + ) AS reset_context + FROM customer_entitlements ce + JOIN entitlements e ON ce.entitlement_id = e.id + JOIN features f ON e.internal_feature_id = f.internal_id + JOIN customers c ON ce.internal_customer_id = c.internal_id + LEFT JOIN customer_products cp ON cp.id = ce.customer_product_id + LEFT JOIN products p ON cp.internal_product_id = p.internal_id + LEFT JOIN pooled_balances pb ON pb.id = ce.pooled_balance_id + LEFT JOIN LATERAL ( + SELECT pcp.status AS parent_status, pcp.subscription_ids AS parent_subscription_ids + FROM customer_licenses pcl + JOIN customer_products pcp ON pcp.id = pcl.parent_customer_product_id + WHERE pcl.link_id = cp.customer_license_link_id + ORDER BY (pcp.status IN (${sql.join( + RELEVANT_STATUSES.map((status) => sql`${status}`), + sql`, `, + )})) DESC, pcl.created_at DESC + LIMIT 1 + ) parent_cp ON true + LEFT JOIN LATERAL ( + SELECT jsonb_agg(to_jsonb(cpr) || jsonb_build_object('price', to_jsonb(pr))) AS customer_prices + FROM customer_prices cpr + JOIN prices pr ON cpr.price_id = pr.id + WHERE cpr.customer_product_id COLLATE "C" = cp.id + ) prices_agg ON true + LEFT JOIN LATERAL ( + SELECT jsonb_agg(to_jsonb(r)) AS rollovers + FROM rollovers r + WHERE e.rollover IS NOT NULL AND r.cus_ent_id = ce.id + ) rollovers_agg ON true + WHERE ce.id = ANY(${sql.param(customerEntitlementIds)}::text[]) + ${resetCronQueryTag("hydrateContext")} +`; + +/** + * Hydrates the requested customer entitlement IDs. IDs can legitimately be + * missing if their rows were deleted between scan and execution. Rows are + * returned as-is — see ResetContextCustomerEntitlement for why hydration is + * unvalidated. + */ +export const getResetContextByIds = async ({ + db, + customerEntitlementIds, +}: { + db: DrizzleCli; + customerEntitlementIds: string[]; +}): Promise<{ + customerEntitlements: ResetContextCustomerEntitlement[]; + missingIds: string[]; +}> => { + const uniqueIds = [...new Set(customerEntitlementIds)]; + if (uniqueIds.length === 0) { + return { customerEntitlements: [], missingIds: [] }; + } + + const rows = await db.execute<{ + id: string; + reset_context: ResetContextCustomerEntitlement; + }>(buildResetContextByIdsQuery({ customerEntitlementIds: uniqueIds })); + + const returnedIds = new Set(rows.map((row) => row.id)); + + return { + customerEntitlements: rows.map((row) => row.reset_context), + missingIds: uniqueIds.filter((id) => !returnedIds.has(id)), + }; +}; diff --git a/server/src/internal/customers/cusProducts/cusEnts/repos/getResetEligibleCustomerEntitlementsPage.ts b/server/src/internal/customers/cusProducts/cusEnts/repos/getResetEligibleCustomerEntitlementsPage.ts new file mode 100644 index 0000000000..9b87c02d2e --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/repos/getResetEligibleCustomerEntitlementsPage.ts @@ -0,0 +1,91 @@ +import { customerEntitlements } from "@autumn/shared"; +import { and, sql } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { resetCronQueryTag } from "@/internal/balances/batchReset/resetCronQueryTag.js"; + +export type ResetScanCursor = { + nextResetAt: number; + id: string; +}; + +export type ResetEligibleCustomerEntitlementRow = { + id: string; + nextResetAt: number; +}; + +/** The partial index covers the stable predicate; expiry and pooled sources remain heap filters. */ +export const resetEligibleFilterSql = ({ dueBefore }: { dueBefore: number }) => + sql`${customerEntitlements.next_reset_at} < ${dueBefore} + AND ${customerEntitlements.expired} IS NOT TRUE + AND ${customerEntitlements.reset_by_invoice} IS NOT TRUE + AND ${customerEntitlements.pooled_contribution_id} IS NULL + AND (${customerEntitlements.expires_at} IS NULL OR ${customerEntitlements.expires_at} > ${dueBefore})`; + +/** The exact scan page statement (tagged). Exported so experiments can + * EXPLAIN what the cron actually runs. */ +export const buildResetEligiblePageQuery = ({ + db, + dueBefore, + cursor, + limit, +}: { + db: DrizzleCli; + dueBefore: number; + cursor: ResetScanCursor | null; + limit: number; +}) => { + const query = db + .select({ + id: customerEntitlements.id, + next_reset_at: customerEntitlements.next_reset_at, + }) + .from(customerEntitlements) + .where( + and( + resetEligibleFilterSql({ dueBefore }), + cursor + ? sql`(${customerEntitlements.next_reset_at}, ${customerEntitlements.id} COLLATE "C") > (${cursor.nextResetAt}, ${cursor.id})` + : undefined, + ), + ) + .orderBy( + customerEntitlements.next_reset_at, + sql`${customerEntitlements.id} COLLATE "C"`, + ) + .limit(limit); + + return sql` + ${query} + ${resetCronQueryTag("scanEligible")} + `; +}; + +/** + * One page of the lightweight reset scan: overdue and not expired. + * Single-table, keyset-paginated on (next_reset_at, id) so page N costs the + * same as page 1. + * + * Eligibility here is intentionally coarse — the batch reset worker classifies + * product and invoice-driven reset rules after hydration. + */ +export const getResetEligibleCustomerEntitlementsPage = async ({ + db, + dueBefore, + cursor, + limit, +}: { + db: DrizzleCli; + dueBefore: number; + cursor: ResetScanCursor | null; + limit: number; +}): Promise => { + const rows = await db.execute<{ + id: string; + next_reset_at: number; + }>(buildResetEligiblePageQuery({ db, dueBefore, cursor, limit })); + + return rows.map((row) => ({ + id: row.id, + nextResetAt: Number(row.next_reset_at), + })); +}; diff --git a/server/src/internal/customers/cusProducts/cusEnts/repos/index.ts b/server/src/internal/customers/cusProducts/cusEnts/repos/index.ts new file mode 100644 index 0000000000..182f7771e7 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/repos/index.ts @@ -0,0 +1,9 @@ +import { countResetEligibleCustomerEntitlements } from "./countResetEligibleCustomerEntitlements.js"; +import { getResetContextByIds } from "./getResetContextByIds.js"; +import { getResetEligibleCustomerEntitlementsPage } from "./getResetEligibleCustomerEntitlementsPage.js"; + +export const customerEntitlementsRepo = { + countResetEligibleCustomerEntitlements, + getResetContextByIds, + getResetEligibleCustomerEntitlementsPage, +}; diff --git a/server/src/internal/customers/cusUtils/initCustomer.ts b/server/src/internal/customers/cusUtils/initCustomer.ts index 3b23a2b864..b52d698de2 100644 --- a/server/src/internal/customers/cusUtils/initCustomer.ts +++ b/server/src/internal/customers/cusUtils/initCustomer.ts @@ -5,6 +5,7 @@ import { type FullCustomer, orgMultiCurrencyEnabled, RecaseError, + stripAutoTopupCountsForStorage, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { generateId } from "@/utils/genUtils.js"; @@ -61,7 +62,9 @@ const initCustomer = ({ processors: customerData?.processors ?? null, send_email_receipts: customerData?.send_email_receipts ?? false, currency: customerData?.currency?.toLowerCase() || null, - auto_topups: customerData?.billing_controls?.auto_topups, + auto_topups: stripAutoTopupCountsForStorage( + customerData?.billing_controls?.auto_topups, + ), spend_limits: customerData?.billing_controls?.spend_limits, usage_limits: customerData?.billing_controls?.usage_limits, usage_alerts: customerData?.billing_controls?.usage_alerts, diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index 0f155cac42..edb0ca859d 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -245,11 +245,8 @@ const buildOptimizedCusProductsCTE = ({ FROM customer_entitlements ce LEFT JOIN pooled_balance_contributions pbc ON pbc.id = ce.pooled_contribution_id - AND ${ - entityId - ? sql`cp.internal_entity_id = (SELECT internal_id FROM entity_record)` - : sql`cp.internal_entity_id IS NULL` - } + AND pbc.source_customer_entitlement_id = ce.id + AND pbc.source_customer_product_id = cp.id WHERE ce.customer_product_id = cp.id ) ce_data ON true LEFT JOIN LATERAL ( @@ -848,9 +845,10 @@ export const getPaginatedFullCusQuery = ({ '[]'::json ) AS customer_entitlements FROM customer_entitlements ce - LEFT JOIN pooled_balance_contributions pbc - ON pbc.id = ce.pooled_contribution_id - AND cp.internal_entity_id IS NULL + LEFT JOIN pooled_balance_contributions pbc + ON pbc.id = ce.pooled_contribution_id + AND pbc.source_customer_entitlement_id = ce.id + AND pbc.source_customer_product_id = cp.id WHERE ce.customer_product_id = cp.id ) ce_data ON true LEFT JOIN LATERAL ( diff --git a/server/src/internal/customers/recovery/customerCreationRecoveryStage.ts b/server/src/internal/customers/recovery/customerCreationRecoveryStage.ts new file mode 100644 index 0000000000..4ab851a2fd --- /dev/null +++ b/server/src/internal/customers/recovery/customerCreationRecoveryStage.ts @@ -0,0 +1,34 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { CustomerCreationRecoveryStage } from "./customerCreationRecoveryTypes.js"; + +const CUSTOMER_CREATION_RECOVERY_STAGE_KEY = "customerCreationRecoveryStage"; + +const RECOVERY_STAGES = new Set([ + "lookup", + "pre_commit", + "existing", + "autumn_committed", + "completed", +]); + +export const setCustomerCreationRecoveryStage = ({ + ctx, + stage, +}: { + ctx: AutumnContext; + stage: CustomerCreationRecoveryStage; +}) => { + ctx.extraLogs[CUSTOMER_CREATION_RECOVERY_STAGE_KEY] = stage; +}; + +export const getCustomerCreationRecoveryStage = ({ + ctx, +}: { + ctx: AutumnContext; +}): CustomerCreationRecoveryStage => { + const stage = ctx.extraLogs[CUSTOMER_CREATION_RECOVERY_STAGE_KEY]; + return typeof stage === "string" && + RECOVERY_STAGES.has(stage as CustomerCreationRecoveryStage) + ? (stage as CustomerCreationRecoveryStage) + : "lookup"; +}; diff --git a/server/src/internal/customers/recovery/customerCreationRecoveryTypes.ts b/server/src/internal/customers/recovery/customerCreationRecoveryTypes.ts new file mode 100644 index 0000000000..a6f8eafb19 --- /dev/null +++ b/server/src/internal/customers/recovery/customerCreationRecoveryTypes.ts @@ -0,0 +1,33 @@ +import type { + ApiVersion, + AppEnv, + CheckParams, + TrackParams, +} from "@autumn/shared"; + +export type CustomerCreationRecoveryStage = + | "lookup" + | "pre_commit" + | "existing" + | "autumn_committed" + | "completed"; + +export type CustomerCreationRecoveryParams = Omit< + TrackParams | CheckParams, + "customer_id" +> & { + customer_id: string | null; +}; + +export interface CustomerCreationRecoveryPayload { + orgId: string; + env: AppEnv; + customerId?: string; + requestId: string; + apiVersion: ApiVersion; + params: CustomerCreationRecoveryParams; + source?: string; + withAutumnId?: boolean; + failureStage: CustomerCreationRecoveryStage; + failedAt: number; +} diff --git a/server/src/internal/customers/recovery/queueFailedCustomerCreation.ts b/server/src/internal/customers/recovery/queueFailedCustomerCreation.ts new file mode 100644 index 0000000000..10ecc23b35 --- /dev/null +++ b/server/src/internal/customers/recovery/queueFailedCustomerCreation.ts @@ -0,0 +1,92 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { JobName } from "@/queue/JobName.js"; +import { addTaskToQueue } from "@/queue/queueUtils.js"; +import type { + CustomerCreationRecoveryParams, + CustomerCreationRecoveryStage, +} from "./customerCreationRecoveryTypes.js"; + +export const CUSTOMER_CREATION_RECOVERY_MESSAGE_GROUP_ID = + "customer-creation-recovery"; + +const getDeduplicationId = ({ + ctx, + params, + withAutumnId, + failureStage, +}: { + ctx: AutumnContext; + params: CustomerCreationRecoveryParams; + withAutumnId?: boolean; + failureStage: CustomerCreationRecoveryStage; +}) => + `customer-creation-${Bun.hash( + JSON.stringify({ + orgId: ctx.org.id, + env: ctx.env, + apiVersion: ctx.apiVersion.value, + params, + withAutumnId, + failureStage, + }), + ).toString(16)}`; + +export const queueFailedCustomerCreation = async ({ + ctx, + params, + source, + withAutumnId, + failureStage, +}: { + ctx: AutumnContext; + params: CustomerCreationRecoveryParams; + source?: string; + withAutumnId?: boolean; + failureStage: CustomerCreationRecoveryStage; +}): Promise => { + const queueUrl = process.env.CUSTOMER_CREATION_RECOVERY_SQS_QUEUE_URL; + if (!queueUrl) { + ctx.logger.error( + "[customerCreationRecovery] Recovery queue URL is not configured", + ); + return false; + } + + try { + await addTaskToQueue({ + jobName: JobName.CustomerCreationRecovery, + queueUrl, + messageGroupId: CUSTOMER_CREATION_RECOVERY_MESSAGE_GROUP_ID, + messageDeduplicationId: getDeduplicationId({ + ctx, + params, + withAutumnId, + failureStage, + }), + generateDeduplicationId: false, + payload: { + orgId: ctx.org.id, + env: ctx.env, + customerId: params.customer_id ?? undefined, + requestId: ctx.id, + apiVersion: ctx.apiVersion.value, + params, + source, + withAutumnId, + failureStage, + failedAt: Date.now(), + }, + }); + ctx.extraLogs.customerCreationRecoveryQueued = { + failureStage, + queueUrl, + }; + return true; + } catch (error) { + ctx.logger.error( + "[customerCreationRecovery] Failed to enqueue customer creation recovery", + { error, failureStage }, + ); + return false; + } +}; diff --git a/server/src/internal/customers/recovery/queueRateLimitedCustomerCreation.ts b/server/src/internal/customers/recovery/queueRateLimitedCustomerCreation.ts new file mode 100644 index 0000000000..1670eed64f --- /dev/null +++ b/server/src/internal/customers/recovery/queueRateLimitedCustomerCreation.ts @@ -0,0 +1,73 @@ +import { + CreateCustomerParamsV0Schema, + CreateCustomerParamsV1Schema, + CustomerDataSchema, +} from "@autumn/shared"; +import type { Context } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { queueFailedCustomerCreation } from "./queueFailedCustomerCreation.js"; + +const GET_OR_CREATE_PATH = "/v1/customers.get_or_create"; +const LEGACY_CREATE_PATH = "/v1/customers"; + +const getWithAutumnIdFromQuery = ({ c }: { c: Context }) => { + const value = c.req.query("with_autumn_id"); + return value === "true" || value === "1"; +}; + +export const queueRateLimitedCustomerCreation = async ({ + c, +}: { + c: Context; +}): Promise => { + const path = c.req.path; + if (c.req.method !== "POST") return false; + if (path !== GET_OR_CREATE_PATH && path !== LEGACY_CREATE_PATH) return false; + + const ctx = c.get("ctx"); + + try { + const body = ctx.requestBody; + if (path === GET_OR_CREATE_PATH) { + const parsed = CreateCustomerParamsV1Schema.safeParse(body); + if (!parsed.success) return false; + if (!parsed.data.customer_id && !parsed.data.email) return false; + + return await queueFailedCustomerCreation({ + ctx, + params: { + customer_id: parsed.data.customer_id, + customer_data: CustomerDataSchema.parse(parsed.data), + entity_id: parsed.data.entity_id, + entity_data: parsed.data.entity_data, + }, + source: "rateLimit:customers.get_or_create", + withAutumnId: parsed.data.with_autumn_id, + failureStage: "lookup", + }); + } + + const parsed = CreateCustomerParamsV0Schema.safeParse(body); + if (!parsed.success) return false; + if (!parsed.data.id && !parsed.data.email) return false; + + return await queueFailedCustomerCreation({ + ctx, + params: { + customer_id: parsed.data.id, + customer_data: CustomerDataSchema.parse(parsed.data), + entity_id: parsed.data.entity_id, + entity_data: parsed.data.entity_data, + }, + source: "rateLimit:customers", + withAutumnId: getWithAutumnIdFromQuery({ c }), + failureStage: "lookup", + }); + } catch (error) { + ctx.logger.error( + "[customerCreationRecovery] Failed to capture rate-limited request", + { error }, + ); + return false; + } +}; diff --git a/server/src/internal/customers/recovery/replayFailedCustomerCreation.ts b/server/src/internal/customers/recovery/replayFailedCustomerCreation.ts new file mode 100644 index 0000000000..e1eeeeb31b --- /dev/null +++ b/server/src/internal/customers/recovery/replayFailedCustomerCreation.ts @@ -0,0 +1,41 @@ +import { ApiVersionClass } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { getOrCreateApiCustomerByRollout } from "@/internal/customers/actions/getOrCreateApiCustomerByRollout.js"; +import type { CustomerCreationRecoveryPayload } from "./customerCreationRecoveryTypes.js"; + +export const replayFailedCustomerCreation = async ({ + ctx, + payload, +}: { + ctx: AutumnContext; + payload: CustomerCreationRecoveryPayload; +}) => { + if (payload.failureStage === "autumn_committed") { + throw new Error( + `Customer creation recovery ${payload.requestId} requires manual billing review`, + ); + } + + ctx.apiVersion = new ApiVersionClass(payload.apiVersion); + + await getOrCreateApiCustomerByRollout({ + ctx, + params: payload.params, + source: "customerCreationRecovery", + withAutumnId: payload.withAutumnId, + enqueueRecoveryOnTransientFailure: false, + }); + + const outcome = + ctx.extraLogs.autumnPlanResult === "created" ? "created" : "fetched"; + ctx.extraLogs.customerCreationRecoveryReplay = { + outcome, + sourceRequestId: payload.requestId, + failureStage: payload.failureStage, + }; + ctx.logger.info("[customerCreationRecovery] Replay completed", { + outcome, + sourceRequestId: payload.requestId, + failureStage: payload.failureStage, + }); +}; diff --git a/server/src/internal/customers/repos/getFullSubject/getEntityOptionsAggregateFragments.ts b/server/src/internal/customers/repos/getFullSubject/getEntityOptionsAggregateFragments.ts index c90652155e..5cc7160b4d 100644 --- a/server/src/internal/customers/repos/getFullSubject/getEntityOptionsAggregateFragments.ts +++ b/server/src/internal/customers/repos/getFullSubject/getEntityOptionsAggregateFragments.ts @@ -12,7 +12,7 @@ import { sql } from "drizzle-orm"; */ export const getEntityOptionsAggregateFragments = () => { const ctes = sql` - entity_option_rows AS ( + entity_option_rows AS MATERIALIZED ( SELECT ecp.internal_customer_id, ecp.id AS customer_product_id, @@ -25,7 +25,7 @@ export const getEntityOptionsAggregateFragments = () => { ) AS option_row(option_value) ), - entity_option_prepaid_rows AS ( + entity_option_prepaid_rows AS MATERIALIZED ( SELECT ce.internal_feature_id, ce.internal_customer_id, @@ -68,7 +68,7 @@ export const getEntityOptionsAggregateFragments = () => { ) ), - entity_prepaid_grant_from_options AS ( + entity_prepaid_grant_from_options AS MATERIALIZED ( SELECT internal_feature_id, internal_customer_id, diff --git a/server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts b/server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts index acf0109dd5..e321f075bd 100644 --- a/server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts +++ b/server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts @@ -1,4 +1,8 @@ -import { type CusProductStatus, RELEVANT_STATUSES } from "@autumn/shared"; +import { + ACTIVE_STATUSES, + type CusProductStatus, + RELEVANT_STATUSES, +} from "@autumn/shared"; import { type SQL, sql } from "drizzle-orm"; import { planetScaleTag } from "@/db/dbUtils.js"; import { notLicenseAssignmentSql } from "@/internal/licenses/repos/licenseAssignmentRepo.js"; @@ -66,13 +70,17 @@ export const getFullSubjectRowsQuery = ({ entityScopedOnly?: boolean; queryTag?: string; }) => { - const statusFilter = + const entityAggregationStatuses = inStatuses.length > 0 + ? ACTIVE_STATUSES.filter((status) => inStatuses.includes(status)) + : ACTIVE_STATUSES; + const entityAggregationStatusFilter = + entityAggregationStatuses.length > 0 ? sql`AND cp.status = ANY(ARRAY[${sql.join( - inStatuses.map((status) => sql`${status}`), + entityAggregationStatuses.map((status) => sql`${status}`), sql`, `, )}])` - : sql``; + : sql`AND FALSE`; // Seats own no lifecycle: their raw status column lags until the seat-sync // cron converges it, so the candidate filter/rank must check the pool @@ -85,11 +93,6 @@ export const getFullSubjectRowsQuery = ({ )}])` : sql``; - const relevantStatusFirst = sql`CASE WHEN cp.status = ANY(ARRAY[${sql.join( - RELEVANT_STATUSES.map((status) => sql`${status}`), - sql`, `, - )}]) THEN 0 ELSE 1 END`; - const effectiveRelevantStatusFirst = sql`CASE WHEN COALESCE(pcp_early.status, cp.status) = ANY(ARRAY[${sql.join( RELEVANT_STATUSES.map((status) => sql`${status}`), sql`, `, @@ -103,7 +106,7 @@ export const getFullSubjectRowsQuery = ({ const entityFragments = includeEntityAggregations ? getEntityAggregateFragments({ - statusFilter, + statusFilter: entityAggregationStatusFilter, }) : emptyEntityFragments; const customerLevelProductPredicate = sql` diff --git a/server/src/internal/features/FeatureService.ts b/server/src/internal/features/FeatureService.ts index 2e06fb33a2..a864c53f91 100644 --- a/server/src/internal/features/FeatureService.ts +++ b/server/src/internal/features/FeatureService.ts @@ -123,13 +123,17 @@ export class FeatureService { .returning(); } + // The row may have been deleted between read and update (e.g. async + // display generation racing an org/feature teardown). + if (updatedFeatures.length === 0) return null; + await clearOrgCache({ db, orgId: updatedFeatures[0].org_id!, env: updatedFeatures[0].env as AppEnv, }); - return updatedFeatures.length > 0 ? (updatedFeatures[0] as Feature) : null; + return updatedFeatures[0] as Feature; } static async insert({ diff --git a/server/src/internal/features/utils/updateFeatureUtils/getObjectsUsingFeature.ts b/server/src/internal/features/utils/updateFeatureUtils/getObjectsUsingFeature.ts index 3b235f107e..3bcb1ef2ab 100644 --- a/server/src/internal/features/utils/updateFeatureUtils/getObjectsUsingFeature.ts +++ b/server/src/internal/features/utils/updateFeatureUtils/getObjectsUsingFeature.ts @@ -38,8 +38,8 @@ export const getObjectsUsingFeature = async ({ }), ]); - const allPrices = allProducts.flatMap((p) => p.prices); - const allEnts = allProducts.flatMap((p) => p.entitlements); + const allPrices = allProducts.flatMap((p) => p.prices ?? []); + const allEnts = allProducts.flatMap((p) => p.entitlements ?? []); const creditSystems = getCreditSystemsFromFeature({ featureId: feature.id, features: ctx.features, diff --git a/server/src/internal/invoices/actions/index.ts b/server/src/internal/invoices/actions/index.ts index 6469ef5c55..3f019f06fe 100644 --- a/server/src/internal/invoices/actions/index.ts +++ b/server/src/internal/invoices/actions/index.ts @@ -1,8 +1,10 @@ +import { insertInvoices } from "./insertInvoices"; import { updateInvoiceFromStripe } from "./updateFromStripe"; import { upsertInvoiceToDbAndCache } from "./upsertDbAndCache"; import { upsertInvoiceFromStripe } from "./upsertFromStripe"; export const invoiceActions = { + insert: insertInvoices, upsertFromStripe: upsertInvoiceFromStripe, updateFromStripe: updateInvoiceFromStripe, upsertToDbAndCache: upsertInvoiceToDbAndCache, diff --git a/server/src/internal/invoices/actions/insertInvoices.ts b/server/src/internal/invoices/actions/insertInvoices.ts new file mode 100644 index 0000000000..adbfa6c040 --- /dev/null +++ b/server/src/internal/invoices/actions/insertInvoices.ts @@ -0,0 +1,276 @@ +import { + type ApiInsertedInvoice, + customers, + ErrCode, + entities, + type InsertInvoice, + type InsertInvoicesParams, + type InsertInvoicesResponse, + invoices, + type ProcessorType, + products, + RecaseError, +} from "@autumn/shared"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer"; +import { generateId } from "@/utils/genUtils"; + +const referenceError = (message: string) => + new RecaseError({ + message, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + +const entityKey = ({ + internalCustomerId, + entityId, +}: { + internalCustomerId: string; + entityId: string; +}) => `${internalCustomerId}:${entityId}`; + +export const insertInvoices = async ({ + ctx, + params, +}: { + ctx: AutumnContext; + params: InsertInvoicesParams; +}): Promise => { + if (params.invoices.length === 0) return { invoices: [] }; + + const stripeIds = params.invoices.map((invoice) => invoice.stripe_id); + if (new Set(stripeIds).size !== stripeIds.length) { + throw referenceError( + "Duplicate stripe_id values are not allowed in one batch", + ); + } + + const customerIds = [ + ...new Set(params.invoices.map((invoice) => invoice.customer_id)), + ]; + const planIds = [ + ...new Set(params.invoices.flatMap((invoice) => invoice.plan_ids)), + ]; + + const latestPlanVersions = ctx.db + .select({ + id: products.id, + maxVersion: sql`MAX(${products.version})`.as("max_version"), + }) + .from(products) + .where( + and( + eq(products.org_id, ctx.org.id), + eq(products.env, ctx.env), + planIds.length > 0 ? inArray(products.id, planIds) : undefined, + ), + ) + .groupBy(products.id) + .as("latest_plan_versions"); + + const [customerRows, planRows, existingRows] = await Promise.all([ + ctx.db + .select({ id: customers.id, internalId: customers.internal_id }) + .from(customers) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + inArray(customers.id, customerIds), + ), + ), + planIds.length > 0 + ? ctx.db + .select({ id: products.id, internalId: products.internal_id }) + .from(products) + .innerJoin( + latestPlanVersions, + and( + eq(products.id, latestPlanVersions.id), + eq(products.version, latestPlanVersions.maxVersion), + ), + ) + .where( + and( + eq(products.org_id, ctx.org.id), + eq(products.env, ctx.env), + inArray(products.id, planIds), + ), + ) + : Promise.resolve([]), + ctx.db + .select({ + stripeId: invoices.stripe_id, + customerId: customers.id, + orgId: customers.org_id, + env: customers.env, + }) + .from(invoices) + .innerJoin( + customers, + eq(invoices.internal_customer_id, customers.internal_id), + ) + .where(inArray(invoices.stripe_id, stripeIds)), + ]); + + const customerById = new Map(customerRows.map((row) => [row.id, row])); + const missingCustomerIds = customerIds.filter((id) => !customerById.has(id)); + if (missingCustomerIds.length > 0) { + throw referenceError( + `Customers not found: ${missingCustomerIds.join(", ")}`, + ); + } + + const planById = new Map(planRows.map((row) => [row.id, row])); + const missingPlanIds = planIds.filter((id) => !planById.has(id)); + if (missingPlanIds.length > 0) { + throw referenceError(`Plans not found: ${missingPlanIds.join(", ")}`); + } + + const foreignInvoice = existingRows.find( + (row) => row.orgId !== ctx.org.id || row.env !== ctx.env, + ); + if (foreignInvoice) { + throw referenceError( + `Invoice ${foreignInvoice.stripeId} belongs to another organization or environment`, + ); + } + + const requestedEntityIds = [ + ...new Set( + params.invoices.flatMap((invoice) => + invoice.entity_id ? [invoice.entity_id] : [], + ), + ), + ]; + const entityRows = + requestedEntityIds.length > 0 + ? await ctx.db + .select({ + id: entities.id, + internalId: entities.internal_id, + internalCustomerId: entities.internal_customer_id, + }) + .from(entities) + .where( + and( + inArray(entities.id, requestedEntityIds), + inArray( + entities.internal_customer_id, + customerRows.map((customer) => customer.internalId), + ), + ), + ) + : []; + const entityByCustomerAndId = new Map( + entityRows.map((entity) => [ + entityKey({ + internalCustomerId: entity.internalCustomerId, + entityId: entity.id ?? "", + }), + entity, + ]), + ); + + const rows: InsertInvoice[] = params.invoices.map((invoice) => { + const customer = customerById.get(invoice.customer_id)!; + const entity = invoice.entity_id + ? entityByCustomerAndId.get( + entityKey({ + internalCustomerId: customer.internalId, + entityId: invoice.entity_id, + }), + ) + : undefined; + if (invoice.entity_id && !entity) { + throw referenceError( + `Entity ${invoice.entity_id} not found for customer ${invoice.customer_id}`, + ); + } + + return { + id: generateId("inv"), + created_at: invoice.created_at, + product_ids: invoice.plan_ids, + internal_product_ids: invoice.plan_ids.map( + (planId) => planById.get(planId)!.internalId, + ), + internal_customer_id: customer.internalId, + internal_entity_id: entity?.internalId ?? null, + stripe_id: invoice.stripe_id, + processor_type: invoice.processor_type, + status: invoice.status, + hosted_invoice_url: invoice.hosted_invoice_url, + total: invoice.total, + amount_paid: invoice.amount_paid, + refunded_amount: invoice.refunded_amount, + currency: invoice.currency ?? ctx.org.default_currency ?? "usd", + discounts: [], + items: [], + }; + }); + + const upsertedRows = await ctx.db + .insert(invoices) + .values(rows) + .onConflictDoUpdate({ + target: invoices.stripe_id, + set: { + created_at: sql`excluded.created_at`, + product_ids: sql`excluded.product_ids`, + internal_product_ids: sql`excluded.internal_product_ids`, + internal_customer_id: sql`excluded.internal_customer_id`, + internal_entity_id: sql`excluded.internal_entity_id`, + processor_type: sql`excluded.processor_type`, + status: sql`excluded.status`, + hosted_invoice_url: sql`excluded.hosted_invoice_url`, + total: sql`excluded.total`, + amount_paid: sql`excluded.amount_paid`, + refunded_amount: sql`excluded.refunded_amount`, + currency: sql`excluded.currency`, + }, + }) + .returning(); + const rowByStripeId = new Map( + upsertedRows.map((invoice) => [invoice.stripe_id, invoice]), + ); + + const responseInvoices: ApiInsertedInvoice[] = params.invoices.map( + (invoice) => { + const row = rowByStripeId.get(invoice.stripe_id)!; + return { + id: row.id, + customer_id: invoice.customer_id, + plan_ids: row.product_ids ?? [], + stripe_id: row.stripe_id, + processor_type: (row.processor_type ?? + invoice.processor_type) as ProcessorType, + status: row.status, + total: row.total, + amount_paid: row.amount_paid ?? null, + refunded_amount: row.refunded_amount, + currency: row.currency, + created_at: row.created_at, + hosted_invoice_url: row.hosted_invoice_url ?? null, + }; + }, + ); + + const customerIdsToInvalidate = new Set(customerIds); + for (const existing of existingRows) { + if (existing.customerId) customerIdsToInvalidate.add(existing.customerId); + } + await Promise.all( + [...customerIdsToInvalidate].map((customerId) => + deleteCachedFullCustomer({ + ctx, + customerId, + source: "invoices.insert", + }), + ), + ); + + return { invoices: responseInvoices }; +}; diff --git a/server/src/internal/invoices/handlers/handleInsertInvoices.ts b/server/src/internal/invoices/handlers/handleInsertInvoices.ts new file mode 100644 index 0000000000..d37363e7e5 --- /dev/null +++ b/server/src/internal/invoices/handlers/handleInsertInvoices.ts @@ -0,0 +1,19 @@ +import { + AffectedResource, + InsertInvoicesParamsSchema, + Scopes, +} from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { invoiceActions } from "@/internal/invoices/actions"; + +export const handleInsertInvoices = createRoute({ + scopes: [Scopes.Billing.Write], + body: InsertInvoicesParamsSchema, + resource: AffectedResource.Invoice, + handler: async (c) => { + const ctx = c.get("ctx"); + const params = c.req.valid("json"); + const result = await invoiceActions.insert({ ctx, params }); + return c.json(result, 200); + }, +}); diff --git a/server/src/internal/invoices/invoiceRouter.ts b/server/src/internal/invoices/invoiceRouter.ts index 11bbac24ad..4187d5b88b 100644 --- a/server/src/internal/invoices/invoiceRouter.ts +++ b/server/src/internal/invoices/invoiceRouter.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { rateLimiter } from "hono-rate-limiter"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { handleGetStripeInvoice } from "./handlers/handleGetStripeInvoice.js"; +import { handleInsertInvoices } from "./handlers/handleInsertInvoices.js"; import { handleListInvoices } from "./handlers/handleListInvoices.js"; import { handleRedirectToInvoice } from "./handlers/handleRedirectToInvoice.js"; @@ -32,4 +33,5 @@ invoiceRouter.get("/:stripe_invoice_id/stripe", ...handleGetStripeInvoice); export const invoiceRpcRouter = new Hono(); +invoiceRpcRouter.post("/invoices.insert", ...handleInsertInvoices); invoiceRpcRouter.post("/invoices.list", ...handleListInvoices); diff --git a/server/src/internal/licenses/actions/links/validateLicenseLink.ts b/server/src/internal/licenses/actions/links/validateLicenseLink.ts index ec8c0c9e90..201bf2d681 100644 --- a/server/src/internal/licenses/actions/links/validateLicenseLink.ts +++ b/server/src/internal/licenses/actions/links/validateLicenseLink.ts @@ -20,6 +20,15 @@ export const validateLicenseLink = ({ if (prepaidOnly !== undefined) { validateLicenseBillingMode({ prepaidOnly }); } + for (const entitlement of licenseProduct.entitlements) { + if (!entitlement.pooled) continue; + + throw new RecaseError({ + message: `Pooled items are not supported for plan licenses (${licensePlanId ?? licenseProduct.id}).`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } if (licensePlanId && licenseProduct.archived) { throw new RecaseError({ message: `License plan ${licensePlanId} is archived and cannot be linked.`, diff --git a/server/src/internal/licenses/repos/licenseAssignmentRepo.ts b/server/src/internal/licenses/repos/licenseAssignmentRepo.ts index 29fd9cbb5a..bb02f9f7cb 100644 --- a/server/src/internal/licenses/repos/licenseAssignmentRepo.ts +++ b/server/src/internal/licenses/repos/licenseAssignmentRepo.ts @@ -13,6 +13,7 @@ import { count, desc, eq, + exists, inArray, isNotNull, isNull, @@ -20,10 +21,17 @@ import { or, sql, } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; import type { DrizzleCli } from "@/db/initDrizzle.js"; export type DbLicenseAssignment = DbCustomerProduct; +const activeCustomerLicense = alias( + customerLicenses, + "active_customer_license", +); +const activeLicenseParent = alias(customerProducts, "active_license_parent"); + const assignmentConditions = () => [ isNotNull(customerProducts.customer_license_link_id), isNotNull(customerProducts.internal_entity_id), @@ -76,7 +84,34 @@ const listAssignmentsWithEntityAndProductByCustomer = async ({ : []), ...assignmentConditions(), ...(activeOnly - ? [inArray(customerProducts.status, ACTIVE_STATUSES)] + ? [ + inArray(customerProducts.status, ACTIVE_STATUSES), + exists( + db + .select({ id: activeCustomerLicense.id }) + .from(activeCustomerLicense) + .innerJoin( + activeLicenseParent, + eq( + activeCustomerLicense.parent_customer_product_id, + activeLicenseParent.id, + ), + ) + .where( + and( + eq( + activeCustomerLicense.link_id, + customerProducts.customer_license_link_id, + ), + eq( + activeCustomerLicense.internal_customer_id, + customerProducts.internal_customer_id, + ), + inArray(activeLicenseParent.status, ACTIVE_STATUSES), + ), + ), + ), + ] : []), ...(entityId ? [eq(entities.id, entityId)] : []), ...(licenseInternalProductId diff --git a/server/src/internal/migrations/v2/filters/customers/filterCustomers.ts b/server/src/internal/migrations/v2/filters/customers/filterCustomers.ts index de80d5469c..0a4f4b58bf 100644 --- a/server/src/internal/migrations/v2/filters/customers/filterCustomers.ts +++ b/server/src/internal/migrations/v2/filters/customers/filterCustomers.ts @@ -76,6 +76,7 @@ export const filterCustomers = ({ includeProcessed, batchSize, limit, + afterInternalId, }: { ctx: AutumnContext; filter: CustomerFilter; @@ -85,6 +86,7 @@ export const filterCustomers = ({ includeProcessed?: IncludeProcessed; batchSize?: number; limit?: number; + afterInternalId?: string; }): AsyncGenerator => { const args = buildArgs({ ctx, filter, checkpoint, search, customerFilters }); const source = iterateOverFilterResults({ @@ -93,6 +95,7 @@ export const filterCustomers = ({ buildRowsSelect({ args, includeProcessed, limit, afterInternalId }), batchSize: limit === undefined ? batchSize : Math.min(batchSize ?? limit, limit), + afterInternalId, }); return limit === undefined ? source : takeRows(source, limit); }; diff --git a/server/src/internal/migrations/v2/filters/iterateOverFilterResults.ts b/server/src/internal/migrations/v2/filters/iterateOverFilterResults.ts index f6d7b9ea02..6c414074fe 100644 --- a/server/src/internal/migrations/v2/filters/iterateOverFilterResults.ts +++ b/server/src/internal/migrations/v2/filters/iterateOverFilterResults.ts @@ -13,12 +13,14 @@ export async function* iterateOverFilterResults< db, buildSelect, batchSize = DEFAULT_BATCH_SIZE, + afterInternalId, }: { db: { execute: (query: SQL) => Promise }; buildSelect: (args: { limit: number; afterInternalId?: string }) => SQL; batchSize?: number; + afterInternalId?: string; }): AsyncGenerator { - let cursor: string | undefined; + let cursor = afterInternalId; while (true) { const query = buildSelect({ limit: batchSize, afterInternalId: cursor }); const rows = (await db.execute(query)) as unknown as TRow[]; diff --git a/server/src/internal/migrations/v2/filters/runFilter.ts b/server/src/internal/migrations/v2/filters/runFilter.ts index acf702e4f4..97375cb125 100644 --- a/server/src/internal/migrations/v2/filters/runFilter.ts +++ b/server/src/internal/migrations/v2/filters/runFilter.ts @@ -28,6 +28,9 @@ export const runFilter = async ({ dryRun, kind, controls, + includeCount = true, + afterInternalId, + batchSize, }: { ctx: AutumnContext; migration: MigrationRuntimeWithEventId; @@ -35,9 +38,12 @@ export const runFilter = async ({ dryRun: boolean; kind: RunScopeKind; controls?: MigrationRunControls; + includeCount?: boolean; + afterInternalId?: string; + batchSize?: number; }): Promise<{ kind: RunScopeKind; - count: number; + count: number | null; iterate: () => AsyncGenerator; }> => { if (kind !== "customer") @@ -56,12 +62,14 @@ export const runFilter = async ({ controls, }); const limit = controls?.limit ?? undefined; - const count = await countCustomers({ - ctx, - filter, - checkpoint, - limit, - }); + const count = includeCount + ? await countCustomers({ + ctx, + filter, + checkpoint, + limit, + }) + : null; ctx.logger.info("runFilter: customer scope resolved", { data: { @@ -94,6 +102,8 @@ export const runFilter = async ({ filter, checkpoint, limit, + afterInternalId, + batchSize, })) { yield batch.map( (row): RunScopeItem => ({ diff --git a/server/src/internal/migrations/v2/handlers/handleRunMigration.ts b/server/src/internal/migrations/v2/handlers/handleRunMigration.ts index ab5067eaaf..6f8d114e02 100644 --- a/server/src/internal/migrations/v2/handlers/handleRunMigration.ts +++ b/server/src/internal/migrations/v2/handlers/handleRunMigration.ts @@ -1,4 +1,4 @@ -import { ErrCode, makeScopeChecker, RecaseError, Scopes } from "@autumn/shared"; +import { ErrCode, RecaseError, Scopes } from "@autumn/shared"; import { auth } from "@trigger.dev/sdk/v3"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler"; @@ -7,49 +7,39 @@ import { prepare } from "@/internal/migrations/v2/prepare/index.js"; import { migrationRepo } from "@/internal/migrations/v2/repos/index.js"; import { RETRYABLE_MIGRATION_ITEM_RUN_STATUSES } from "@/internal/migrations/v2/run/utils/retryItemStatuses.js"; import { shouldRunMigrationInline } from "@/internal/migrations/v2/utils/shouldRunMigrationInline.js"; +import { + getMigrationTriggerOptions, + MIGRATION_RUN_CUSTOMER_CONCURRENCY, +} from "@/trigger/migrations/migrationTaskQueue.js"; import { executeRunMigration, type RunMigrationPayload, runMigrationTask, } from "@/trigger/migrations/runMigrationTask.js"; -const MAX_CONCURRENCY = 5; -const SUPERUSER_MAX_CONCURRENCY = 100; +const LEGACY_MAX_REQUESTED_CONCURRENCY = 100; const RunMigrationBody = z.object({ id: z.string(), dry_run: z.boolean().default(false), limit: z.number().int().min(1).optional(), only: z.array(z.string()).optional(), + /** Accepted for backward compatibility; migration concurrency is fleet-managed. */ concurrency: z .number() .int() .min(1) - .max(SUPERUSER_MAX_CONCURRENCY) - .optional(), + .max(LEGACY_MAX_REQUESTED_CONCURRENCY) + .optional() + .describe("Deprecated: migration concurrency is fleet-managed"), retry_item_statuses: z .array(z.enum(RETRYABLE_MIGRATION_ITEM_RUN_STATUSES)) .optional(), - /** When true, claim a lazy run alongside the background sweeper. Customers - * hit on the request path get migrated lazily via `runMigrationCustomerTask` - * before the sweeper reaches them. Background and lazy run on the same - * migration_run row — the claim is shared. */ + /** Lazy runs share one run row with the sweeper and enqueue request-path customer work. + * Targeted `only` is incompatible because lazy matching happens on customer reads. */ lazy_run: z.boolean().default(false), }); -const getRunMigrationTriggerOptions = ({ - orgId, - migrationId, - isDev, -}: { - orgId: string; - migrationId: string; - isDev: boolean; -}) => ({ - ...(isDev ? { region: "eu-central-1" } : {}), - concurrencyKey: `${orgId}:${migrationId}`, -}); - export const handleRunMigration = createRoute({ scopes: [Scopes.Migrations.Write], body: RunMigrationBody, @@ -60,23 +50,10 @@ export const handleRunMigration = createRoute({ dry_run: dryRun, limit, only, - concurrency, retry_item_statuses: retryItemStatuses, lazy_run: lazyRun, } = c.req.valid("json"); - const { isSuperuser } = makeScopeChecker(ctx.scopes); - const maxConcurrency = isSuperuser - ? SUPERUSER_MAX_CONCURRENCY - : MAX_CONCURRENCY; - if (concurrency !== undefined && concurrency > maxConcurrency) { - throw new RecaseError({ - message: `Migration concurrency cannot exceed ${maxConcurrency}`, - code: ErrCode.InvalidRequest, - statusCode: 400, - }); - } - const migration = await migrationRepo.find({ ctx, id }); if (!migration.operations) @@ -119,7 +96,6 @@ export const handleRunMigration = createRoute({ controls: { limit, only, - concurrency, retryItemStatuses, }, }; @@ -129,11 +105,7 @@ export const handleRunMigration = createRoute({ } const handle = await runMigrationTask.trigger( payload, - getRunMigrationTriggerOptions({ - orgId: ctx.org.id, - migrationId: id, - isDev, - }), + getMigrationTriggerOptions({ isDev }), ); return { triggerRunId: handle.id }; }, @@ -176,7 +148,7 @@ export const handleRunMigration = createRoute({ migration_id: id, dry_run: dryRun, lazy_run: lazyRun, - concurrency, + concurrency: MIGRATION_RUN_CUSTOMER_CONCURRENCY, run_id: migrationRunId, trigger_run_id: triggerRunId, public_access_token: publicAccessToken, diff --git a/server/src/internal/migrations/v2/lazy/checkPendingMigrationsForCustomer.ts b/server/src/internal/migrations/v2/lazy/checkPendingMigrationsForCustomer.ts index 3c0bf2ac1b..27fb9dbbb1 100644 --- a/server/src/internal/migrations/v2/lazy/checkPendingMigrationsForCustomer.ts +++ b/server/src/internal/migrations/v2/lazy/checkPendingMigrationsForCustomer.ts @@ -2,6 +2,7 @@ import type { FullCustomer, MigrationItemRunData } from "@autumn/shared"; import { customerFilterMatchesFullCustomer } from "@autumn/shared/api/customers/utils/match/index.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { shouldRunMigrationInline } from "@/internal/migrations/v2/utils/shouldRunMigrationInline.js"; +import { MIGRATION_LAZY_TASK_PRIORITY_SECONDS } from "@/trigger/migrations/migrationTaskQueue.js"; import { executeRunMigrationCustomer, runMigrationCustomerTask, @@ -80,8 +81,6 @@ export const checkPendingMigrationsForCustomer = async ({ }; if (shouldRunMigrationInline()) { - // Inline loses trigger.dev's concurrencyKey serialization; the - // server-side item-run claim is the real authority either way. const inlineCtx = { ...ctx, insideTriggerTask: true }; void executeRunMigrationCustomer({ ctx: inlineCtx, @@ -99,7 +98,7 @@ export const checkPendingMigrationsForCustomer = async ({ } await runMigrationCustomerTask.trigger(payload, { - concurrencyKey: `${migration.internal_id}:${fullCustomer.internal_id}`, + priority: MIGRATION_LAZY_TASK_PRIORITY_SECONDS, }); } }; diff --git a/server/src/internal/migrations/v2/run/orchestrators/iterateScope.ts b/server/src/internal/migrations/v2/run/orchestrators/iterateScope.ts index 8dfbec99d9..b0baea5614 100644 --- a/server/src/internal/migrations/v2/run/orchestrators/iterateScope.ts +++ b/server/src/internal/migrations/v2/run/orchestrators/iterateScope.ts @@ -1,44 +1,54 @@ +import type { MigrationRunScheduler } from "../types/migrationRunScheduler.js"; import type { RunScopeItem } from "../types/runScope.js"; export type IterateScopeItemResult = | { status: "ok"; item: RunScopeItem; value: T } | { status: "failed"; item: RunScopeItem; error: Error }; +export type IterateScopeCompletion = "exhausted" | "slice_complete" | "stopped"; + export type IterateScopeSummary = { processed: number; succeeded: number; failed: number; results: IterateScopeItemResult[]; + completion: IterateScopeCompletion; + cursor: string | null; }; -/** - * Generic iteration over any kind-tagged scope iterator. Calls `perItem` - * for every item, collecting per-item results into a summary. - * - * `concurrency` (default 1): max parallel `perItem` invocations. The - * iterator's batch boundaries are preserved — work for batch N starts - * only after the source yields it, but within and across batches up to - * `concurrency` items run concurrently via a sliding worker pool. - * - * On error: keeps going with `onError: "continue"` (default), or rethrows - * the first error with `onError: "throw"`. Either way every visited - * item shows up in `results` so callers see the full audit trail. - */ +/** Iterates scope items; a scheduler forces sequential execution and ends between items. + * Errors are collected by default or rethrown when `onError` is `throw`. */ export const iterateScope = async ({ iterate, perItem, onError = "continue", concurrency = 1, + scheduler, + shouldStop, }: { iterate: () => AsyncGenerator; perItem: (item: RunScopeItem) => Promise; onError?: "throw" | "continue"; concurrency?: number; + scheduler?: MigrationRunScheduler; + shouldStop?: () => boolean; }): Promise> => { const results: IterateScopeItemResult[] = []; let succeeded = 0; let failed = 0; - const maxParallel = Math.max(1, Math.floor(concurrency)); + const maxParallel = scheduler ? 1 : Math.max(1, Math.floor(concurrency)); + const sliceStartedAtMs = scheduler?.now(); + let hasProcessedScheduledItem = false; + const summarize = ( + completion: IterateScopeCompletion, + ): IterateScopeSummary => ({ + processed: succeeded + failed, + succeeded, + failed, + results, + completion, + cursor: results[results.length - 1]?.item.internal_id ?? null, + }); const runItem = async (item: RunScopeItem) => { try { @@ -53,11 +63,23 @@ export const iterateScope = async ({ } }; + const scheduledSliceIsComplete = () => + scheduler !== undefined && + hasProcessedScheduledItem && + sliceStartedAtMs !== undefined && + scheduler.now() - sliceStartedAtMs >= scheduler.sliceDurationMs; + if (maxParallel === 1) { for await (const batch of iterate()) { - for (const item of batch) await runItem(item); + for (const item of batch) { + if (shouldStop?.()) return summarize("stopped"); + if (scheduledSliceIsComplete()) return summarize("slice_complete"); + await runItem(item); + hasProcessedScheduledItem = true; + if (shouldStop?.()) return summarize("stopped"); + } } - return { processed: succeeded + failed, succeeded, failed, results }; + return summarize("exhausted"); } const inflight = new Set>(); @@ -70,13 +92,21 @@ export const iterateScope = async ({ for await (const batch of iterate()) { for (const item of batch) { + if (shouldStop?.()) { + await Promise.all(inflight); + return summarize("stopped"); + } schedule(item); if (inflight.size >= maxParallel) { await Promise.race(inflight); + if (shouldStop?.()) { + await Promise.all(inflight); + return summarize("stopped"); + } } } } await Promise.all(inflight); - return { processed: succeeded + failed, succeeded, failed, results }; + return summarize(shouldStop?.() ? "stopped" : "exhausted"); }; diff --git a/server/src/internal/migrations/v2/run/orchestrators/runScopeIteration.ts b/server/src/internal/migrations/v2/run/orchestrators/runScopeIteration.ts index 3f27febab2..681151c388 100644 --- a/server/src/internal/migrations/v2/run/orchestrators/runScopeIteration.ts +++ b/server/src/internal/migrations/v2/run/orchestrators/runScopeIteration.ts @@ -12,6 +12,7 @@ import { type MigrationRuntimeWithEventId, } from "../../types/migrationDefinition.js"; import { migrateCustomer } from "../migrateCustomer/index.js"; +import type { MigrationRunScheduler } from "../types/migrationRunScheduler.js"; import type { RunScopeItem, RunScopeKind } from "../types/runScope.js"; import { isMigrationCancelRequested } from "../utils/migrationCancelToken.js"; import { iterateScope } from "./iterateScope.js"; @@ -26,6 +27,9 @@ export const runScopeIteration = async ({ batch, controls, hooks, + scheduler, + includeFilterCount, + afterInternalId, }: { ctx: AutumnContext; migration: MigrationRuntimeWithEventId; @@ -35,6 +39,9 @@ export const runScopeIteration = async ({ batch?: MigrationBatchFn; controls?: MigrationRunControls; hooks?: MigrationHooks; + scheduler?: MigrationRunScheduler; + includeFilterCount?: boolean; + afterInternalId?: string; }) => { const { count, iterate } = await runFilter({ ctx, @@ -43,6 +50,9 @@ export const runScopeIteration = async ({ dryRun, kind, controls, + includeCount: includeFilterCount, + afterInternalId, + batchSize: scheduler?.batchSize, }); ctx.logger.info(`run-migration: iterating scope`, { data: { kind, count, dryRun }, @@ -67,7 +77,10 @@ export const runScopeIteration = async ({ `runMigration: per-item handler missing for kind "${item.kind}"`, ); - if (!cancelRequested && (await isMigrationCancelRequested({ migrationRunId }))) + if ( + !cancelRequested && + (await isMigrationCancelRequested({ migrationRunId })) + ) cancelRequested = true; if (cancelRequested) { itemCtx.logger.info("run-migration: skipping item, cancel requested", { @@ -117,6 +130,8 @@ export const runScopeIteration = async ({ iterate, perItem: (item) => perItem({ item, itemCtx: ctx }), concurrency: controls?.concurrency, + scheduler, + shouldStop: () => cancelRequested, }); } @@ -124,7 +139,7 @@ export const runScopeIteration = async ({ batch, iterate, kind, - controls, + controls: scheduler ? { ...controls, concurrency: 1 } : controls, perItem, }); }; diff --git a/server/src/internal/migrations/v2/run/runMigration.ts b/server/src/internal/migrations/v2/run/runMigration.ts index d21fde89bd..160e8678f7 100644 --- a/server/src/internal/migrations/v2/run/runMigration.ts +++ b/server/src/internal/migrations/v2/run/runMigration.ts @@ -12,69 +12,140 @@ import { import { prepare } from "../prepare/index.js"; import { type MigrationRuntime, + type MigrationRuntimeWithEventId, withMigrationEventId, } from "../types/migrationDefinition.js"; +import type { IterateScopeCompletion } from "./orchestrators/iterateScope.js"; import { runScopeIteration } from "./orchestrators/runScopeIteration.js"; import { preProcessMigration } from "./preProcess/index.js"; import { getRunScopes } from "./types/getRunScopes.js"; +import type { MigrationRunScheduler } from "./types/migrationRunScheduler.js"; -/** Top-level migration run: prepare -> per-scope filter+iterate -> per-item ops. */ -export const runMigration = async ({ +export type RunMigrationResult = { + processed: number; + completion: IterateScopeCompletion; + cursor: string | null; +}; + +export const prepareMigration = async ({ ctx, migration, - migrationRunId, dryRun, - batch, - controls, - hooks, - plugins, }: { ctx: AutumnContext; migration: MigrationRuntime; - migrationRunId?: string; dryRun: boolean; - batch?: MigrationBatchFn; - controls?: MigrationRunControls; - hooks?: RunMigrationHooks; - plugins?: RunMigrationPlugin[]; -}): Promise => { - const eventMigrationRunId = migrationRunId ?? generateId("mrun"); - const migrationHooks = composeMigrationHooks({ hooks, plugins }); +}): Promise => { const migrationWithEventId = withMigrationEventId({ orgId: ctx.org.id, env: ctx.env, migration, }); - - // Inject default guards (e.g. `custom: false` on version-bumping - // update_plan ops, both at the op-level plan_filter and at the - // migration.filter customer.plan level) so admin-customized - // customer_products are never touched. Has to run before `prepare` - // so the prepared state reflects the guarded filter. const guardedMigration = preProcessMigration(migrationWithEventId); - const { preparedState } = await prepare({ ctx, migration: guardedMigration, dryRun, }); - const preparedMigration = { + + return { ...guardedMigration, prepared_state: preparedState, }; +}; + +export const runPreparedMigration = async ({ + ctx, + migration, + migrationRunId, + dryRun, + batch, + controls, + hooks, + scheduler, + includeFilterCount = true, + afterInternalId, +}: { + ctx: AutumnContext; + migration: MigrationRuntimeWithEventId; + migrationRunId: string; + dryRun: boolean; + batch?: MigrationBatchFn; + controls?: MigrationRunControls; + hooks?: RunMigrationHooks; + scheduler?: MigrationRunScheduler; + includeFilterCount?: boolean; + afterInternalId?: string; +}): Promise => { + let processed = 0; + let cursor: string | null = null; - for (const kind of getRunScopes({ migration: preparedMigration })) { - await runScopeIteration({ + for (const kind of getRunScopes({ migration })) { + const result = await runScopeIteration({ ctx, - migration: preparedMigration, - migrationRunId: eventMigrationRunId, + migration, + migrationRunId, dryRun, kind, batch, controls, - hooks: migrationHooks, + hooks, + scheduler, + includeFilterCount, + afterInternalId, }); + if (!result) continue; + processed += result.processed; + cursor = result.cursor; + + if ("completion" in result && result.completion !== "exhausted") { + return { processed, completion: result.completion, cursor }; + } } + + return { processed, completion: "exhausted", cursor }; +}; + +/** Top-level migration run: prepare -> per-scope filter+iterate -> per-item ops. */ +export const runMigration = async ({ + ctx, + migration, + migrationRunId, + dryRun, + batch, + controls, + hooks, + plugins, + scheduler, +}: { + ctx: AutumnContext; + migration: MigrationRuntime; + migrationRunId?: string; + dryRun: boolean; + batch?: MigrationBatchFn; + controls?: MigrationRunControls; + hooks?: RunMigrationHooks; + plugins?: RunMigrationPlugin[]; + scheduler?: MigrationRunScheduler; +}): Promise => { + const eventMigrationRunId = migrationRunId ?? generateId("mrun"); + const migrationHooks = composeMigrationHooks({ hooks, plugins }); + const preparedMigration = await prepareMigration({ + ctx, + migration, + dryRun, + }); + + return runPreparedMigration({ + ctx, + migration: preparedMigration, + migrationRunId: eventMigrationRunId, + dryRun, + batch, + controls, + hooks: migrationHooks, + scheduler, + }); }; export type { RunMigrationHooks, RunMigrationPlugin }; diff --git a/server/src/internal/migrations/v2/run/types/migrationRunScheduler.ts b/server/src/internal/migrations/v2/run/types/migrationRunScheduler.ts new file mode 100644 index 0000000000..4c2d9f48c7 --- /dev/null +++ b/server/src/internal/migrations/v2/run/types/migrationRunScheduler.ts @@ -0,0 +1,5 @@ +export type MigrationRunScheduler = { + batchSize: number; + sliceDurationMs: number; + now: () => number; +}; diff --git a/server/src/internal/migrations/v2/utils/shouldRunMigrationInline.ts b/server/src/internal/migrations/v2/utils/shouldRunMigrationInline.ts index 2d10bf6842..f44e06a445 100644 --- a/server/src/internal/migrations/v2/utils/shouldRunMigrationInline.ts +++ b/server/src/internal/migrations/v2/utils/shouldRunMigrationInline.ts @@ -1,7 +1 @@ -// tw-swarm-only fallback: keyless VMs run migrations in-process. Gated on -// TW_WORKER_MODE so a forgotten prod key fails loudly (migrations need Trigger's durable layer). -export const shouldRunMigrationInline = () => - process.env.TW_WORKER_MODE === "1" && - process.env.NODE_ENV !== "production" && - !process.env.TRIGGER_SERVER_SECRET_KEY && - !process.env.TRIGGER_SECRET_KEY; +export { shouldRunTriggerTasksInline as shouldRunMigrationInline } from "@/trigger/utils/shouldRunTriggerTasksInline.js"; diff --git a/server/src/internal/misc/batchReset/batchResetConfigSchemas.ts b/server/src/internal/misc/batchReset/batchResetConfigSchemas.ts new file mode 100644 index 0000000000..27b008dcb3 --- /dev/null +++ b/server/src/internal/misc/batchReset/batchResetConfigSchemas.ts @@ -0,0 +1,7 @@ +import { z } from "zod/v4"; + +export const BatchResetConfigSchema = z.object({ + enabled: z.boolean().default(true), +}); + +export type BatchResetConfig = z.infer; diff --git a/server/src/internal/misc/batchReset/batchResetConfigStore.ts b/server/src/internal/misc/batchReset/batchResetConfigStore.ts new file mode 100644 index 0000000000..cbd268fc6a --- /dev/null +++ b/server/src/internal/misc/batchReset/batchResetConfigStore.ts @@ -0,0 +1,37 @@ +import { ADMIN_BATCH_RESET_CONFIG_KEY } from "@/external/aws/s3/adminS3Config.js"; +import { registerEdgeConfig } from "@/internal/misc/edgeConfig/edgeConfigRegistry.js"; +import { createEdgeConfigStore } from "@/internal/misc/edgeConfig/edgeConfigStore.js"; +import { + type BatchResetConfig, + BatchResetConfigSchema, +} from "./batchResetConfigSchemas.js"; + +const store = createEdgeConfigStore({ + s3Key: ADMIN_BATCH_RESET_CONFIG_KEY, + schema: BatchResetConfigSchema, + defaultValue: () => BatchResetConfigSchema.parse({}), +}); + +registerEdgeConfig({ store }); + +export const isBatchResetEnabled = (): boolean => store.get().enabled; + +export const getBatchResetConfigStatus = () => store.getStatus(); + +export const getBatchResetConfigFromSource = async () => store.readFromSource(); + +export const updateFullBatchResetConfig = async ({ + config, +}: { + config: BatchResetConfig; +}) => { + await store.writeToSource({ config }); +}; + +export const _setBatchResetConfigForTesting = ({ + config, +}: { + config: BatchResetConfig; +}): void => { + store._setRuntimeConfigForTesting(config); +}; diff --git a/server/src/internal/misc/jobQueues/jobQueueStore.ts b/server/src/internal/misc/jobQueues/jobQueueStore.ts index c95ede39b2..7a764b1bef 100644 --- a/server/src/internal/misc/jobQueues/jobQueueStore.ts +++ b/server/src/internal/misc/jobQueues/jobQueueStore.ts @@ -10,6 +10,9 @@ export const JOB_QUEUE_IDS = { primary: "primary", track: "track", trackAsync: "trackAsync", + customerCreationRecovery: "customerCreationRecovery", + stripeWebhookReplay: "stripeWebhookReplay", + batchReset: "batchReset", } as const; export const KNOWN_JOB_QUEUES = [ @@ -32,6 +35,27 @@ export const KNOWN_JOB_QUEUES = [ description: "Customer-driven async / batch track ingest.", defaultEnabled: true, }, + { + id: JOB_QUEUE_IDS.customerCreationRecovery, + label: "Customer Creation Recovery Queue", + description: + "Serialized replay queue for transient customer get-or-create failures.", + defaultEnabled: false, + }, + { + id: JOB_QUEUE_IDS.stripeWebhookReplay, + label: "Stripe Webhook Replay Queue", + description: + "Replay queue for failed early-acked Stripe webhooks. Enable to drain after recovery.", + defaultEnabled: false, + }, + { + id: JOB_QUEUE_IDS.batchReset, + label: "Batch Reset Queue", + description: + "Dedicated queue for batch customer entitlement resets fanned out by the reset scanner.", + defaultEnabled: true, + }, ] as const; const store = createEdgeConfigStore({ diff --git a/server/src/internal/misc/mainRedisCache/mainRedisCacheSchemas.ts b/server/src/internal/misc/mainRedisCache/mainRedisCacheSchemas.ts new file mode 100644 index 0000000000..ce2146309e --- /dev/null +++ b/server/src/internal/misc/mainRedisCache/mainRedisCacheSchemas.ts @@ -0,0 +1,10 @@ +import { z } from "zod/v4"; + +export const MainRedisInstanceName = z.enum(["primary", "fallback"]); +export type MainRedisInstanceName = z.infer; + +export const MainRedisCacheConfigSchema = z.object({ + activeInstance: MainRedisInstanceName.default("primary"), +}); + +export type MainRedisCacheConfig = z.infer; diff --git a/server/src/internal/misc/mainRedisCache/mainRedisCacheStore.ts b/server/src/internal/misc/mainRedisCache/mainRedisCacheStore.ts new file mode 100644 index 0000000000..ec06c24fb0 --- /dev/null +++ b/server/src/internal/misc/mainRedisCache/mainRedisCacheStore.ts @@ -0,0 +1,39 @@ +import { ms } from "@autumn/shared"; +import { ADMIN_MAIN_REDIS_CACHE_CONFIG_KEY } from "@/external/aws/s3/adminS3Config.js"; +import { registerEdgeConfig } from "@/internal/misc/edgeConfig/edgeConfigRegistry.js"; +import { createEdgeConfigStore } from "@/internal/misc/edgeConfig/edgeConfigStore.js"; +import { + type MainRedisCacheConfig, + MainRedisCacheConfigSchema, + type MainRedisInstanceName, +} from "./mainRedisCacheSchemas.js"; + +const store = createEdgeConfigStore({ + s3Key: ADMIN_MAIN_REDIS_CACHE_CONFIG_KEY, + schema: MainRedisCacheConfigSchema, + defaultValue: () => ({ activeInstance: "primary" }), + pollIntervalMs: ms.seconds(10), +}); + +registerEdgeConfig({ store }); + +export const getActiveMainRedisInstance = (): MainRedisInstanceName => + store.get().activeInstance; + +export const getMainRedisCacheStatus = () => store.getStatus(); + +export const updateActiveMainRedisInstance = async ({ + activeInstance, +}: { + activeInstance: MainRedisInstanceName; +}) => { + await store.writeToSource({ config: { activeInstance } }); +}; + +export const _setActiveMainRedisInstanceForTesting = ({ + activeInstance, +}: { + activeInstance: MainRedisInstanceName; +}) => { + store._setRuntimeConfigForTesting({ activeInstance }); +}; diff --git a/server/src/internal/misc/miscellaneousEdgeConfig/miscellaneousEdgeConfigSchemas.ts b/server/src/internal/misc/miscellaneousEdgeConfig/miscellaneousEdgeConfigSchemas.ts index 9d8039a953..c16d6261ee 100644 --- a/server/src/internal/misc/miscellaneousEdgeConfig/miscellaneousEdgeConfigSchemas.ts +++ b/server/src/internal/misc/miscellaneousEdgeConfig/miscellaneousEdgeConfigSchemas.ts @@ -2,6 +2,9 @@ import { z } from "zod/v4"; export const MiscellaneousEdgeConfigSchema = z.object({ newFlatCusModel: z.array(z.string()).default([]), + /** Global switch: coalesce balance syncs via per-customer Redis dirty state + * (signal-only SQS messages). Dark by default. */ + syncCoalesce: z.boolean().default(false), }); export type MiscellaneousEdgeConfig = z.infer< diff --git a/server/src/internal/misc/miscellaneousEdgeConfig/miscellaneousEdgeConfigStore.ts b/server/src/internal/misc/miscellaneousEdgeConfig/miscellaneousEdgeConfigStore.ts index b833a36daf..ea25bd5bb2 100644 --- a/server/src/internal/misc/miscellaneousEdgeConfig/miscellaneousEdgeConfigStore.ts +++ b/server/src/internal/misc/miscellaneousEdgeConfig/miscellaneousEdgeConfigStore.ts @@ -52,3 +52,6 @@ export const _setMiscellaneousEdgeConfigForTesting = ({ }): void => { store._setRuntimeConfigForTesting(config); }; + +/** Global sync-coalescing gate (balance syncs via Redis dirty state). */ +export const isSyncCoalesceEnabled = (): boolean => store.get().syncCoalesce; diff --git a/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts b/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts index e226577430..28afbb5d43 100644 --- a/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts +++ b/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts @@ -190,8 +190,8 @@ export type RateLimitConfig = { windowMs: number; notInRedis: boolean; scope: RateLimitScope; - // "degrade" = over-limit requests fail open (check -> allow, track -> SQS - // queue) instead of 429, so the cap sheds DB load without losing events. + // "degrade" preserves check/track traffic through fallback paths. + // Establish routes still reject with 429. overLimit?: "reject" | "degrade"; }; diff --git a/server/src/internal/misc/rateLimiter/rateLimitFactory.ts b/server/src/internal/misc/rateLimiter/rateLimitFactory.ts index 34feef1827..c3db3afccc 100644 --- a/server/src/internal/misc/rateLimiter/rateLimitFactory.ts +++ b/server/src/internal/misc/rateLimiter/rateLimitFactory.ts @@ -4,6 +4,7 @@ import { rateLimiter } from "hono-rate-limiter"; import { logger } from "@/external/logtail/logtailUtils.js"; import { shouldUseRedis } from "@/external/redis/initRedis"; import type { HonoEnv } from "@/honoUtils/HonoEnv"; +import { queueRateLimitedCustomerCreation } from "@/internal/customers/recovery/queueRateLimitedCustomerCreation.js"; import { isCheckFailOpenRoute, RATE_LIMIT_CONFIGS, @@ -82,8 +83,8 @@ export const rateLimitFactory = ({ return resolveRateLimit({ config, apiVersion }).limit; }; - // Over-limit "degrade": fail open instead of 429 — check routes get the - // allow-fallback via the ctx flag; establish routes shed a retryable 503. + // Check routes fail open; establish routes reject with a standard 429. + // Track routes use the degradation flag to preserve events through SQS. const degradeHandler = async ( c: Context, next: Next, @@ -93,13 +94,17 @@ export const rateLimitFactory = ({ warnOrgCapExceeded({ limitType: type, orgSlug: ctx?.org?.slug }); if (type === RateLimitType.CheckOrg && !isCheckFailOpenRoute(honoContext)) { + // Clients fail open on this 429. Preserve valid customer creation + // requests for controlled, serialized replay after the incident. + await queueRateLimitedCustomerCreation({ c: honoContext }); + c.header("Retry-After", undefined); return c.json( { - message: "Service is temporarily unavailable, please retry shortly.", - code: "service_unavailable", + message: "Rate limit exceeded.", + code: "rate_limit_exceeded", env: ctx?.env, }, - 503, + 429, ); } diff --git a/server/src/internal/misc/resetJob/resetJobSchemas.ts b/server/src/internal/misc/resetJob/resetJobSchemas.ts new file mode 100644 index 0000000000..294b17d90c --- /dev/null +++ b/server/src/internal/misc/resetJob/resetJobSchemas.ts @@ -0,0 +1,16 @@ +import { z } from "zod/v4"; + +export const DEFAULT_RESET_BATCH_SIZE = 500; +export const MAX_RESET_BATCH_SIZE = 2_000; + +export const ResetJobConfigSchema = z.object({ + enabled: z.boolean().default(false), + batchSize: z + .number() + .int() + .min(1) + .max(MAX_RESET_BATCH_SIZE) + .default(DEFAULT_RESET_BATCH_SIZE), +}); + +export type ResetJobConfig = z.infer; diff --git a/server/src/internal/misc/resetJob/resetJobStore.ts b/server/src/internal/misc/resetJob/resetJobStore.ts new file mode 100644 index 0000000000..e99b6058be --- /dev/null +++ b/server/src/internal/misc/resetJob/resetJobStore.ts @@ -0,0 +1,35 @@ +import { ADMIN_RESET_JOB_CONFIG_KEY } from "@/external/aws/s3/adminS3Config.js"; +import { registerEdgeConfig } from "@/internal/misc/edgeConfig/edgeConfigRegistry.js"; +import { createEdgeConfigStore } from "@/internal/misc/edgeConfig/edgeConfigStore.js"; +import { + type ResetJobConfig, + ResetJobConfigSchema, +} from "./resetJobSchemas.js"; + +const store = createEdgeConfigStore({ + s3Key: ADMIN_RESET_JOB_CONFIG_KEY, + schema: ResetJobConfigSchema, + defaultValue: () => ResetJobConfigSchema.parse({}), +}); + +registerEdgeConfig({ store }); + +export const getResetJobConfig = () => store.get(); + +export const isResetJobEnabled = () => getResetJobConfig().enabled; + +export const getResetJobConfigStatus = () => store.getStatus(); + +export const getResetJobConfigFromSource = async () => store.readFromSource(); + +export const updateResetJobConfig = async ({ + config, +}: { + config: ResetJobConfig; +}) => store.writeToSource({ config }); + +export const setResetJobConfigForTesting = ({ + config, +}: { + config: ResetJobConfig; +}) => store._setRuntimeConfigForTesting(config); diff --git a/server/src/internal/misc/resetJobV2/resetJobV2Schemas.ts b/server/src/internal/misc/resetJobV2/resetJobV2Schemas.ts new file mode 100644 index 0000000000..eff48b7ee3 --- /dev/null +++ b/server/src/internal/misc/resetJobV2/resetJobV2Schemas.ts @@ -0,0 +1,55 @@ +import { z } from "zod/v4"; + +export const RESET_JOB_V2_CONFIG_LIMITS = { + scanBatchSize: { min: 1, max: 10_000 }, + workerBatchSize: { min: 1, max: 1_000 }, + maxConcurrentJobs: { min: 1, max: 100 }, + scanIntervalMs: { min: 1_000, max: 60_000 }, + queueHighWaterMessages: { min: 1, max: 10_000 }, + queueDepthPollMs: { min: 1_000, max: 60_000 }, +} as const; + +export const ResetJobV2ConfigSchema = z.object({ + enabled: z.boolean().default(false), + scanBatchSize: z + .number() + .int() + .min(RESET_JOB_V2_CONFIG_LIMITS.scanBatchSize.min) + .max(RESET_JOB_V2_CONFIG_LIMITS.scanBatchSize.max) + .default(500), + workerBatchSize: z + .number() + .int() + .min(RESET_JOB_V2_CONFIG_LIMITS.workerBatchSize.min) + .max(RESET_JOB_V2_CONFIG_LIMITS.workerBatchSize.max) + .default(500), + maxConcurrentJobs: z + .number() + .int() + .min(RESET_JOB_V2_CONFIG_LIMITS.maxConcurrentJobs.min) + .max(RESET_JOB_V2_CONFIG_LIMITS.maxConcurrentJobs.max) + .default(3), + scanIntervalMs: z + .number() + .int() + .min(RESET_JOB_V2_CONFIG_LIMITS.scanIntervalMs.min) + .max(RESET_JOB_V2_CONFIG_LIMITS.scanIntervalMs.max) + .default(1_000), + // Scanner backpressure: pause scanning while the batch reset queue holds + // more than this many messages (visible + in-flight). + queueHighWaterMessages: z + .number() + .int() + .min(RESET_JOB_V2_CONFIG_LIMITS.queueHighWaterMessages.min) + .max(RESET_JOB_V2_CONFIG_LIMITS.queueHighWaterMessages.max) + .default(20), + // How often the scan gates re-check queue depth while waiting. + queueDepthPollMs: z + .number() + .int() + .min(RESET_JOB_V2_CONFIG_LIMITS.queueDepthPollMs.min) + .max(RESET_JOB_V2_CONFIG_LIMITS.queueDepthPollMs.max) + .default(5_000), +}); + +export type ResetJobV2Config = z.infer; diff --git a/server/src/internal/misc/resetJobV2/resetJobV2Store.ts b/server/src/internal/misc/resetJobV2/resetJobV2Store.ts new file mode 100644 index 0000000000..18185f9c41 --- /dev/null +++ b/server/src/internal/misc/resetJobV2/resetJobV2Store.ts @@ -0,0 +1,35 @@ +import { ADMIN_RESET_JOB_V2_CONFIG_KEY } from "@/external/aws/s3/adminS3Config.js"; +import { registerEdgeConfig } from "@/internal/misc/edgeConfig/edgeConfigRegistry.js"; +import { createEdgeConfigStore } from "@/internal/misc/edgeConfig/edgeConfigStore.js"; +import { + type ResetJobV2Config, + ResetJobV2ConfigSchema, +} from "./resetJobV2Schemas.js"; + +const store = createEdgeConfigStore({ + s3Key: ADMIN_RESET_JOB_V2_CONFIG_KEY, + schema: ResetJobV2ConfigSchema, + defaultValue: () => ResetJobV2ConfigSchema.parse({}), +}); + +registerEdgeConfig({ store }); + +export const getResetJobV2Config = () => store.get(); + +export const isResetJobV2Enabled = () => getResetJobV2Config().enabled; + +export const getResetJobV2ConfigStatus = () => store.getStatus(); + +export const getResetJobV2ConfigFromSource = async () => store.readFromSource(); + +export const updateResetJobV2Config = async ({ + config, +}: { + config: ResetJobV2Config; +}) => store.writeToSource({ config }); + +export const setResetJobV2ConfigForTesting = ({ + config, +}: { + config: ResetJobV2Config; +}) => store._setRuntimeConfigForTesting(config); diff --git a/server/src/internal/product/actions/updateProduct/validateVariantSettingsUpdate.ts b/server/src/internal/product/actions/updateProduct/validateVariantSettingsUpdate.ts index ed5ff40153..58b7212d08 100644 --- a/server/src/internal/product/actions/updateProduct/validateVariantSettingsUpdate.ts +++ b/server/src/internal/product/actions/updateProduct/validateVariantSettingsUpdate.ts @@ -1,6 +1,7 @@ import { ErrCode, type FullProduct, + ProductConfigSchema, type ProductV2, RecaseError, type UpdateProductV2Params, @@ -63,8 +64,15 @@ const normalizeVariantSettingValue = ({ // "" while a description-less plan stores null, which isn't a real change. if (field === "description") return value || null; if (field === "billing_controls") return canonicalizeBillingControls(value); - if (["config", "metadata"].includes(field)) { - return JSON.stringify(value ?? {}); + // The read path defaults config through ProductConfigSchema (so the editor + // echoes back {ignore_past_due:false}) while the DB stores a bare {}. Parse + // both sides so those defaults line up and don't read as a change. + if (field === "config") { + const parsed = ProductConfigSchema.safeParse(value ?? {}); + return JSON.stringify(sortDeep(parsed.success ? parsed.data : (value ?? {}))); + } + if (field === "metadata") { + return JSON.stringify(sortDeep(value ?? {})); } return value; }; diff --git a/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts b/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts index 01a3cba784..c5924a9c18 100644 --- a/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts +++ b/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts @@ -134,10 +134,13 @@ export const handleCopyProducts = async ({ await Promise.all(operations); + // inIds bypasses the products cache — the copy ops' invalidations land + // async, so a plain listFull can still see the pre-copy (empty) snapshot. const copiedToProducts = await ProductService.listFull({ db, orgId: toOrg.id, env: toEnv, + inIds: fromProducts.map((product) => product.id), }); await copyPlanLicenseLinks({ db, diff --git a/server/src/internal/products/product-items/validateProductItems.ts b/server/src/internal/products/product-items/validateProductItems.ts index 01debae1e2..68f094148a 100644 --- a/server/src/internal/products/product-items/validateProductItems.ts +++ b/server/src/internal/products/product-items/validateProductItems.ts @@ -49,6 +49,29 @@ const validateProductItem = ({ }); } + if ( + item.pooled && + (feature?.type === FeatureType.Boolean || item.included_usage === Infinite) + ) { + throw new RecaseError({ + message: "Pooled items are only supported for finite metered features", + code: ErrCode.InvalidProductItem, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + if ( + item.pooled && + isFeaturePriceItem(item) && + item.usage_model === UsageModel.PayPerUse + ) { + throw new RecaseError({ + message: "Pooled items cannot use usage-based pricing", + code: ErrCode.InvalidProductItem, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + // 1. Check if amount and tiers are not null if (notNullish(item.price) && notNullish(item.tiers)) { throw new RecaseError({ diff --git a/server/src/internal/products/productUtils/productResponseUtils/getPlanResponse.ts b/server/src/internal/products/productUtils/productResponseUtils/getPlanResponse.ts index 9361707734..6cfa1ec733 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getPlanResponse.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getPlanResponse.ts @@ -64,8 +64,8 @@ export const getPlanResponse = async ({ }): Promise => { // 1. Convert prices/entitlements to items const rawItems = mapToProductItems({ - prices: product.prices, - entitlements: product.entitlements, + prices: product.prices ?? [], + entitlements: product.entitlements ?? [], features: features, }); @@ -158,10 +158,10 @@ export const getPlanResponse = async ({ licenses: apiLicenses, free_trial: freeTrial, - created_at: product.created_at, - env: product.env, + created_at: product.created_at ?? 0, + env: product.env ?? ctx?.env ?? "sandbox", archived: product.archived, - base_variant_id: product.base_variant_id, + base_variant_id: product.base_variant_id ?? null, config: product.config ?? { ignore_past_due: false }, billing_controls: billingControlsFromColumns(product), diff --git a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts index f3efe38065..658fa53cc6 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts @@ -152,7 +152,7 @@ const getProductProperties = ({ is_one_off: isOneOffProduct({ product }), interval_group: largestInterval?.interval, has_trial: hasFreeTrial, - updateable: product.prices.some( + updateable: (product.prices ?? []).some( (p: Price) => isPrepaidPrice(p) && p.config.interval !== BillingInterval.OneOff, ), @@ -178,8 +178,8 @@ export const getProductResponse = async ({ }) => { // 1. Get items with display const rawItems = mapToProductItems({ - prices: product.prices, - entitlements: product.entitlements, + prices: product.prices ?? [], + entitlements: product.entitlements ?? [], features: features, }); diff --git a/server/src/internal/products/productV2Utils.ts b/server/src/internal/products/productV2Utils.ts index 3cd9e3771a..8f4ab65273 100644 --- a/server/src/internal/products/productV2Utils.ts +++ b/server/src/internal/products/productV2Utils.ts @@ -23,6 +23,8 @@ export const mapToProductItems = ({ allowFeatureMatch?: boolean; }): ProductItem[] => { const items: ProductItem[] = []; + prices ??= []; + entitlements ??= []; for (const ent of entitlements) { const relatedPrice = getEntRelatedPrice(ent, prices, allowFeatureMatch); @@ -60,16 +62,18 @@ export const mapToProductV2 = ({ features: Feature[]; }): ProductV2 => { const items: ProductItem[] = []; + const prices = product.prices ?? []; + const entitlements = product.entitlements ?? []; // console.log("Prices:", product.prices); // console.log("Entitlements:", product.entitlements); - for (const ent of product.entitlements) { - const relatedPrice = getEntRelatedPrice(ent, product.prices); + for (const ent of entitlements) { + const relatedPrice = getEntRelatedPrice(ent, prices); items.push(toProductItem({ ent, price: relatedPrice })); } - for (const price of product.prices) { - const relatedEnt = getPriceEntitlement(price, product.entitlements); + for (const price of prices) { + const relatedEnt = getPriceEntitlement(price, entitlements); // console.log("Price:", price.id); // console.log("Related ent:", relatedEnt); diff --git a/server/src/queue/JobName.ts b/server/src/queue/JobName.ts index bcf11058b9..408e0832b4 100644 --- a/server/src/queue/JobName.ts +++ b/server/src/queue/JobName.ts @@ -19,13 +19,19 @@ export enum JobName { SyncBalanceBatchV2 = "sync-balance-batch-v2", SyncBalanceBatchV3 = "sync-balance-batch-v3", SyncBalanceBatchV4 = "sync-balance-batch-v4", + /** Signal-only sync: payload identifies the customer; selectors and + * usage-window snapshots live in the Redis dirty state. */ + SyncCustomerDirty = "sync-customer-dirty", RefreshEntityAggregate = "refresh-entity-aggregate", InsertEventBatch = "insert-event-batch", Track = "track", + CustomerCreationRecovery = "customer-creation-recovery", ClearCreditSystemCustomerCache = "clear-credit-system-customer-cache", BatchResetCusEnts = "batch-reset-cus-ents", + /** ID-based reset worker (balances/batchReset); supersedes BatchResetCusEnts */ + BatchResetCustomerEntitlementsV2 = "batch-reset-customer-entitlements-v2", AutoTopUp = "auto-top-up", /** Stores invoice line items from Stripe to DB (async to allow extra API calls) */ @@ -34,6 +40,9 @@ export enum JobName { /** Stores deferred invoice line items (ProrateNextCycle pending items) before an invoice exists */ StoreDeferredInvoiceLineItems = "store-deferred-invoice-line-items", + /** Replays failed early-acked Stripe webhooks from the dedicated queue */ + StripeWebhookReplay = "stripe-webhook-replay", + // Hatchet workflows VerifyCacheConsistency = "verify-cache-consistency", diff --git a/server/src/queue/blueGreen/blueGreenReadinessChecks.ts b/server/src/queue/blueGreen/blueGreenReadinessChecks.ts index 63b0511139..41e68cb714 100644 --- a/server/src/queue/blueGreen/blueGreenReadinessChecks.ts +++ b/server/src/queue/blueGreen/blueGreenReadinessChecks.ts @@ -45,6 +45,7 @@ const getConfiguredQueueUrls = () => QUEUE_URL, process.env.TRACK_SQS_QUEUE_URL, process.env.TRACK_ASYNC_SQS_QUEUE_URL, + process.env.CUSTOMER_CREATION_RECOVERY_SQS_QUEUE_URL, ].filter((url): url is string => Boolean(url)); export const getBlueGreenQueueUrls = ({ @@ -61,8 +62,7 @@ export const getBlueGreenQueueUrls = ({ // the SDK falls back to (the queue URL's host). const sqsClientsByRegion = new Map(); const getSqsClientForQueue = (queueUrl: string): SQSClient => { - const region = - extractRegionFromQueueUrl({ queueUrl }) ?? DEFAULT_AWS_REGION; + const region = extractRegionFromQueueUrl({ queueUrl }) ?? DEFAULT_AWS_REGION; const cached = sqsClientsByRegion.get(region); if (cached) return cached; const client = new SQSClient({ region }); diff --git a/server/src/queue/concurrency/getQueueConcurrencyPolicy.ts b/server/src/queue/concurrency/getQueueConcurrencyPolicy.ts new file mode 100644 index 0000000000..93a66d5e8c --- /dev/null +++ b/server/src/queue/concurrency/getQueueConcurrencyPolicy.ts @@ -0,0 +1,25 @@ +import { JOB_QUEUE_IDS } from "@/internal/misc/jobQueues/jobQueueStore.js"; +import { getResetJobV2Config } from "@/internal/misc/resetJobV2/resetJobV2Store.js"; + +export type QueueConcurrencyPolicy = { + redisKey: string; + maxConcurrentMessages: number; + leaseMs: number; +}; + +export const getQueueConcurrencyPolicy = ({ + queueId, +}: { + queueId: string; +}): QueueConcurrencyPolicy | null => { + switch (queueId) { + case JOB_QUEUE_IDS.batchReset: + return { + redisKey: "queue:batch-reset-v2:active-jobs", + maxConcurrentMessages: getResetJobV2Config().maxConcurrentJobs, + leaseMs: 90_000, + }; + default: + return null; + } +}; diff --git a/server/src/queue/concurrency/queueCapacityLease.ts b/server/src/queue/concurrency/queueCapacityLease.ts new file mode 100644 index 0000000000..ca4b020b79 --- /dev/null +++ b/server/src/queue/concurrency/queueCapacityLease.ts @@ -0,0 +1,178 @@ +import type { Redis } from "ioredis"; +import { getPrimaryRedis } from "@/external/redis/initRedis.js"; +import { runRedisOp } from "@/external/redis/utils/runRedisOp.js"; +import { generateId } from "@/utils/genUtils.js"; +import { getQueueConcurrencyPolicy } from "./getQueueConcurrencyPolicy.js"; + +const ACQUIRE_PERMITS_SCRIPT = ` +local now = tonumber(ARGV[1]) +local expiresAt = tonumber(ARGV[2]) +local concurrencyLimit = tonumber(ARGV[3]) +local requested = tonumber(ARGV[4]) + +redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", now) + +local active = redis.call("ZCARD", KEYS[1]) +local available = math.max(0, concurrencyLimit - active) +local acquired = math.min(requested, available) + +for index = 1, acquired do + redis.call("ZADD", KEYS[1], expiresAt, ARGV[4 + index]) +end + +if acquired > 0 then + redis.call("PEXPIRE", KEYS[1], math.max(1, expiresAt - now) * 2) +end + +return acquired +`; + +const RELEASE_PERMIT_SCRIPT = ` +local removed = redis.call("ZREM", KEYS[1], ARGV[1]) + +if redis.call("ZCARD", KEYS[1]) == 0 then + redis.call("DEL", KEYS[1]) +end + +return removed +`; + +type QueueConcurrencyPermit = { + release: () => Promise; +}; + +export type LeasedQueueItem = { + item: T; + release: () => Promise; +}; + +export type QueueCapacityLease = { + capacity: number; + isLimited: boolean; + assign: (items: T[]) => Promise[]>; + release: () => Promise; +}; + +const releaseImmediately = () => Promise.resolve(); + +const releasePermits = async (permits: QueueConcurrencyPermit[]) => { + await Promise.allSettled(permits.map((permit) => permit.release())); +}; + +const createPermit = ({ + redis, + redisKey, + token, +}: { + redis: Redis; + redisKey: string; + token: string; +}): QueueConcurrencyPermit => { + let released = false; + + return { + release: async () => { + if (released) return; + released = true; + + try { + await runRedisOp({ + source: "queue-concurrency:release", + redisInstance: redis, + operation: () => + redis.eval(RELEASE_PERMIT_SCRIPT, 1, redisKey, token), + }); + } catch { + // The permit lease expires automatically if Redis cannot release it. + } + }, + }; +}; + +const createUnlimitedLease = ({ + requested, +}: { + requested: number; +}): QueueCapacityLease => ({ + capacity: requested, + isLimited: false, + assign: async (items: T[]) => + items.map((item) => ({ + item, + release: releaseImmediately, + })), + release: releaseImmediately, +}); + +const createLimitedLease = ({ + permits, +}: { + permits: QueueConcurrencyPermit[]; +}): QueueCapacityLease => ({ + capacity: permits.length, + isLimited: true, + assign: async (items: T[]) => { + await releasePermits(permits.slice(items.length)); + + return items.map((item, index) => ({ + item, + release: permits[index].release, + })); + }, + release: () => releasePermits(permits), +}); + +export const reserveQueueCapacity = async ({ + queueId, + requested, +}: { + queueId: string; + requested: number; +}): Promise => { + const requestedCount = Math.max(0, Math.floor(requested)); + if (requestedCount === 0) return null; + + const policy = getQueueConcurrencyPolicy({ queueId }); + if (!policy) return createUnlimitedLease({ requested: requestedCount }); + + const acquiredAt = Date.now(); + const tokens = Array.from({ length: requestedCount }, () => + generateId("queue_permit"), + ); + + try { + const redis = getPrimaryRedis(); + const result = await runRedisOp({ + source: "queue-concurrency:acquire", + redisInstance: redis, + operation: () => + redis.eval( + ACQUIRE_PERMITS_SCRIPT, + 1, + policy.redisKey, + acquiredAt, + acquiredAt + policy.leaseMs, + policy.maxConcurrentMessages, + requestedCount, + ...tokens, + ), + }); + const acquiredResult = Number(result); + if (!Number.isFinite(acquiredResult)) { + return createUnlimitedLease({ requested: requestedCount }); + } + + const acquired = Math.max(0, Math.min(requestedCount, acquiredResult)); + if (acquired === 0) return null; + + const permits = tokens + .slice(0, acquired) + .map((token) => + createPermit({ redis, redisKey: policy.redisKey, token }), + ); + return createLimitedLease({ permits }); + } catch { + // Fail open to normal SQS capacity so Redis cannot stop queue processing. + return createUnlimitedLease({ requested: requestedCount }); + } +}; diff --git a/server/src/queue/createWorkerContext.ts b/server/src/queue/createWorkerContext.ts index 0e724596c5..de86bad318 100644 --- a/server/src/queue/createWorkerContext.ts +++ b/server/src/queue/createWorkerContext.ts @@ -28,15 +28,18 @@ export const createWorkerContext = async ({ const { orgId, env, customerId, requestId } = payload; if (!orgId || !env) return; - // Fetch org with features once for all items - const orgData = await OrgService.getWithFeatures({ - db, - orgId, - env, - }); + // Fetch org with features once for all items. A missing org means it was + // deleted after the job was queued (common in tests) — skip, don't fail. + let orgData: Awaited> | null; + try { + orgData = await OrgService.getWithFeatures({ db, orgId, env }); + } catch { + orgData = null; + } if (!orgData) { - throw new Error(`Organization not found: ${orgId}, env: ${env}`); + logger.warn(`Org ${orgId} (${env}) not found — skipping queued job`); + return; } const { org, features } = orgData; diff --git a/server/src/queue/initWorkers.ts b/server/src/queue/initWorkers.ts index dce8b4dd22..490702a199 100644 --- a/server/src/queue/initWorkers.ts +++ b/server/src/queue/initWorkers.ts @@ -26,6 +26,10 @@ import { recordPollAttempt, } from "./blueGreen/blueGreenHeartbeat.js"; import { initBlueGreen, shutdownBlueGreen } from "./blueGreen/initBlueGreen.js"; +import { + type QueueCapacityLease, + reserveQueueCapacity, +} from "./concurrency/queueCapacityLease.js"; import { getSqsClient, QUEUE_URL, recreateSqsClient } from "./initSqs.js"; import { JobName } from "./JobName.js"; import { processMessage, type SqsJob } from "./processMessage.js"; @@ -44,24 +48,32 @@ const shouldIdleSelfKill = process.env.NODE_ENV !== "development"; // Per-message processing timeout — must be under VisibilityTimeout (30s) const MESSAGE_TIMEOUT_MS = 25_000; +const SQS_RECEIVE_BATCH_LIMIT = 10; +const QUEUE_CAPACITY_RETRY_MS = 1_000; type JobOverride = { - ackUpfront: true; + ack: "upfront" | "always-after-processing"; dispatch: "inline" | "background"; }; -// Jobs whose handlers can exceed the 30s VisibilityTimeout. ACK upfront to -// avoid redelivery loops; dispatch mode controls whether the poll loop awaits -// them (inline → preserves backpressure) or fires in background (→ no -// backpressure; only safe for genuinely rare, low-volume jobs). +// Jobs with nonstandard acknowledgement or dispatch behavior. Inline dispatch +// preserves backpressure; background dispatch is only safe for rare, +// low-volume work that does not use a shared concurrency limit. const JOB_OVERRIDES: Partial> = { // Rare (handful per day); fire-and-forget is safe. - [JobName.Migration]: { ackUpfront: true, dispatch: "background" }, + [JobName.Migration]: { ack: "upfront", dispatch: "background" }, // Can exceed VisibilityTimeout on large orgs; redelivery causes a // self-amplifying Redis UNLINK storm. Inline so one worker's concurrency // stays capped at the receive batch size. [JobName.ClearCreditSystemCustomerCache]: { - ackUpfront: true, + ack: "upfront", + dispatch: "inline", + }, + // A failed reset remains overdue and will be rediscovered by the next scan. + // Keep it in flight until processing finishes so queue depth and concurrency + // remain accurate, then ACK failures too so SQS cannot retry indefinitely. + [JobName.BatchResetCustomerEntitlementsV2]: { + ack: "always-after-processing", dispatch: "inline", }, }; @@ -85,18 +97,25 @@ const logPrefix = ({ queueUrl }: { queueUrl: string }) => export const startPollingLoop = async ({ db, + queueId, queueUrl, isFifo, getSqsClientFn, recreateSqsClientFn, shouldPoll = () => true, + visibilityTimeoutSeconds = 30, }: { db: DrizzleCli; + queueId: string; queueUrl: string; isFifo: boolean; getSqsClientFn: () => SQSClient; recreateSqsClientFn: () => SQSClient; shouldPoll?: () => boolean; + /** Raise for queues whose jobs legitimately run long (e.g. batch resets) — + * a message redelivered mid-processing means two workers mutating the same + * rows concurrently. */ + visibilityTimeoutSeconds?: number; }) => { // Per-loop state let messagesProcessed = 0; @@ -191,12 +210,16 @@ export const startPollingLoop = async ({ lastStatsTime = Date.now(); }; - const createReceiveCommand = () => + const createReceiveCommand = ({ + maxNumberOfMessages, + }: { + maxNumberOfMessages: number; + }) => new ReceiveMessageCommand({ QueueUrl: queueUrl, - MaxNumberOfMessages: 10, + MaxNumberOfMessages: maxNumberOfMessages, WaitTimeSeconds: 20, - VisibilityTimeout: 30, + VisibilityTimeout: visibilityTimeoutSeconds, MessageSystemAttributeNames: ["SentTimestamp", "ApproximateReceiveCount"], ...(isFifo && { ReceiveRequestAttemptId: generateId("receive") }), }); @@ -233,24 +256,34 @@ export const startPollingLoop = async ({ const job: SqsJob = JSON.parse(message.Body); const override = getJobOverride(job.name); - if (override?.ackUpfront) { + if (override?.ack === "upfront") { await ackMessageUpfront({ sqs, message, job }); } - if (override) { - await processMessage({ message, db }); - } else { - await withTimeout({ - timeoutMs: MESSAGE_TIMEOUT_MS, - timeoutMessage: `Processing timed out after ${MESSAGE_TIMEOUT_MS}ms`, - fn: () => processMessage({ message, db }), - }); + try { + if (override) { + await processMessage({ message, db }); + } else { + await withTimeout({ + timeoutMs: MESSAGE_TIMEOUT_MS, + timeoutMessage: `Processing timed out after ${MESSAGE_TIMEOUT_MS}ms`, + fn: () => processMessage({ message, db }), + }); + } + } catch (error) { + if (override?.ack !== "always-after-processing") throw error; + + console.error( + `${prefix} ${job.name} failed; ACKing after processing so the next scan can retry it:`, + error instanceof Error ? error.message : error, + ); + Sentry.captureException(error); } messagesProcessed++; totalMessagesProcessed++; - if (message.ReceiptHandle && !override?.ackUpfront) { + if (message.ReceiptHandle && override?.ack !== "upfront") { return { id: message.MessageId!, receiptHandle: message.ReceiptHandle }; } return null; @@ -333,6 +366,7 @@ export const startPollingLoop = async ({ let sqs = getSqsClientFn(); while (isRunning) { + let capacityLease: QueueCapacityLease | null = null; try { if (!shouldPoll()) { consecutiveEmptyPolls = 0; @@ -340,23 +374,43 @@ export const startPollingLoop = async ({ continue; } - recordPollAttempt({ queueUrl }); - const response = await sqs.send(createReceiveCommand(), { - abortSignal: abortController.signal, + capacityLease = await reserveQueueCapacity({ + queueId, + requested: SQS_RECEIVE_BATCH_LIMIT, }); + if (!capacityLease) { + await new Promise((resolve) => + setTimeout(resolve, QUEUE_CAPACITY_RETRY_MS), + ); + continue; + } + + recordPollAttempt({ queueUrl }); + const response = await sqs.send( + createReceiveCommand({ + maxNumberOfMessages: capacityLease.capacity, + }), + { + abortSignal: abortController.signal, + }, + ); const messages = response.Messages ?? []; + const leasedMessages = await capacityLease.assign(messages); if (messages.length > 0) { recordMessagesReceived({ queueUrl, count: messages.length }); consecutiveEmptyPolls = 0; - const regularMessages: Message[] = []; - for (const message of messages) { + const regularMessages: { + message: Message; + releaseCapacity: () => Promise; + }[] = []; + for (const { item: message, release } of leasedMessages) { if (!message.Body) continue; const job: SqsJob = JSON.parse(message.Body); const override = getJobOverride(job.name); - if (override?.dispatch === "background") { + if (override?.dispatch === "background" && !capacityLease.isLimited) { activeMigrationJobs++; handleSingleMessage({ sqs, message, db }) .catch((error) => { @@ -368,13 +422,18 @@ export const startPollingLoop = async ({ }) .finally(() => activeMigrationJobs--); } else { - regularMessages.push(message); + regularMessages.push({ + message, + releaseCapacity: release, + }); } } const results = await Promise.allSettled( - regularMessages.map((message) => - handleSingleMessage({ sqs, message, db }), + regularMessages.map(({ message, releaseCapacity }) => + handleSingleMessage({ sqs, message, db }).finally(() => + releaseCapacity(), + ), ), ); @@ -404,6 +463,8 @@ export const startPollingLoop = async ({ const newClient = await handlePollingError(error); if (newClient) sqs = newClient; else if ((error as { name?: string }).name === "AbortError") break; + } finally { + await capacityLease?.release(); } } @@ -459,18 +520,47 @@ export const initWorkers = async ({ ); const pollingLoops = []; - for (const { queueId, queueUrl } of [ + for (const { + queueId, + queueUrl, + defaultEnabled, + visibilityTimeoutSeconds, + } of [ { queueId: JOB_QUEUE_IDS.primary, queueUrl: QUEUE_URL, + defaultEnabled: true, }, { queueId: JOB_QUEUE_IDS.track, queueUrl: process.env.TRACK_SQS_QUEUE_URL, + defaultEnabled: true, }, { queueId: JOB_QUEUE_IDS.trackAsync, queueUrl: process.env.TRACK_ASYNC_SQS_QUEUE_URL, + defaultEnabled: true, + }, + { + queueId: JOB_QUEUE_IDS.customerCreationRecovery, + queueUrl: process.env.CUSTOMER_CREATION_RECOVERY_SQS_QUEUE_URL, + defaultEnabled: false, + }, + { + queueId: JOB_QUEUE_IDS.stripeWebhookReplay, + queueUrl: process.env.STRIPE_WEBHOOK_SQS_QUEUE_URL, + defaultEnabled: false, + }, + { + queueId: JOB_QUEUE_IDS.batchReset, + queueUrl: process.env.BATCH_RESET_SQS_QUEUE_URL, + defaultEnabled: true, + // Reset batches can legitimately run long (Stripe anchor checks on + // month-edge dates); a short window would redeliver mid-processing and + // have two workers resetting the same rows concurrently. SQS maximum + // (12h) — failed resets are re-found by the next scan, so redelivery + // latency doesn't matter. + visibilityTimeoutSeconds: 43_200, }, ]) { if (!queueUrl) continue; @@ -478,13 +568,15 @@ export const initWorkers = async ({ pollingLoops.push( startPollingLoop({ db, + queueId, queueUrl, isFifo: queueUrl.endsWith(".fifo"), getSqsClientFn: () => getSqsClient({ queueUrl }), recreateSqsClientFn: () => recreateSqsClient({ queueUrl }), shouldPoll: () => - isJobQueueEnabled({ queue: queueId }) && + isJobQueueEnabled({ queue: queueId, defaultEnabled }) && isActiveSlot({ serviceName: "workers" }), + visibilityTimeoutSeconds, }), ); } diff --git a/server/src/queue/processMessage.ts b/server/src/queue/processMessage.ts index f3067ebb0d..b2563eac93 100644 --- a/server/src/queue/processMessage.ts +++ b/server/src/queue/processMessage.ts @@ -6,24 +6,32 @@ import { isTransientDbError } from "@/db/dbUtils.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { logger } from "@/external/logtail/logtailUtils.js"; import { isTransientRedisError } from "@/external/redis/utils/isTransientRedisError.js"; +import { + runStripeWebhookReplay, + StripeWebhookReplayInFlightError, +} from "@/external/stripe/webhookReplay/runStripeWebhookReplay.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js"; import { autoTopup } from "@/internal/balances/autoTopUp/autoTopup.js"; +import { batchResetCustomerEntitlementsV2 } from "@/internal/balances/batchReset/batchResetCustomerEntitlementsV2.js"; import { runInsertEventBatch } from "@/internal/balances/events/runInsertEventBatch.js"; import { expireLock } from "@/internal/balances/finalizeLock/expireLock.js"; import { runQueuedTrack } from "@/internal/balances/track/runQueuedTrack.js"; import { refreshEntityAggregateCache } from "@/internal/balances/utils/refreshEntityAggregate/index.js"; import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js"; import { syncItemV4 } from "@/internal/balances/utils/sync/syncItemV4.js"; +import { syncItemV5 } from "@/internal/balances/utils/sync/syncItemV5.js"; import { grantCheckoutReward } from "@/internal/billing/v2/workflows/grantCheckoutReward/grantCheckoutReward.js"; import { sendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.js"; import { storeDeferredInvoiceLineItems } from "@/internal/billing/v2/workflows/storeDeferredInvoiceLineItems/storeDeferredInvoiceLineItems.js"; import { storeInvoiceLineItems } from "@/internal/billing/v2/workflows/storeInvoiceLineItems/storeInvoiceLineItems.js"; import { batchResetCustomerEntitlements } from "@/internal/customers/actions/resetCustomerEntitlements/batchResetCustomerEntitlements.js"; +import { replayFailedCustomerCreation } from "@/internal/customers/recovery/replayFailedCustomerCreation.js"; import { runClearCreditSystemCacheTask } from "@/internal/features/featureActions/runClearCreditSystemCacheTask.js"; import { generateFeatureDisplay } from "@/internal/features/workflows/generateFeatureDisplay.js"; import { runMigrationTask } from "@/internal/migrations/runMigrationTask.js"; import { runRewardMigrationTask } from "@/internal/migrations/runRewardMigrationTask.js"; +import { isBatchResetEnabled } from "@/internal/misc/batchReset/batchResetConfigStore.js"; import { detectBaseVariant } from "@/internal/products/productUtils/detectProductVariant.js"; import { runTriggerCheckoutReward } from "@/internal/rewards/actions/triggerCheckoutReward.js"; import { generateId } from "@/utils/genUtils.js"; @@ -52,12 +60,23 @@ export const shouldRetrySqsJobError = ({ error: unknown; }) => { switch (jobName) { + case JobName.CustomerCreationRecovery: + return isTransientDbError({ error }) || isTransientRedisError({ error }); case JobName.SyncBalanceBatchV3: case JobName.SyncBalanceBatchV4: case JobName.RefreshEntityAggregate: return isTransientDbError({ error }); + // Signal jobs are meaningless without Redis: an unreachable Redis must + // leave the message in SQS for redelivery, not swallow-and-ack. + case JobName.SyncCustomerDirty: case JobName.Track: return isTransientDbError({ error }) || isTransientRedisError({ error }); + case JobName.StripeWebhookReplay: + return ( + error instanceof StripeWebhookReplayInFlightError || + isTransientDbError({ error }) || + isTransientRedisError({ error }) + ); default: return false; } @@ -95,6 +114,23 @@ export const processMessage = async ({ let workerCtx: AutumnContext | undefined; const executeJob = async () => { + if (job.name === JobName.BatchResetCusEnts && !isBatchResetEnabled()) { + workerLogger.info( + "Batch reset skipped because the edge config is disabled", + ); + return; + } + + // Reset-ID payload (no orgId/env): builds its own per-org contexts. + if (job.name === JobName.BatchResetCustomerEntitlementsV2) { + await batchResetCustomerEntitlementsV2({ + db, + logger: workerLogger, + payload: job.data, + }); + return; + } + if (job.name === JobName.DetectBaseVariant) { await detectBaseVariant({ db, @@ -138,6 +174,29 @@ export const processMessage = async ({ return; } + if (job.name === JobName.CustomerCreationRecovery) { + if (!ctx) { + throw new Error("No context found for customer creation recovery job"); + } + await replayFailedCustomerCreation({ + ctx, + payload: job.data, + }); + return; + } + + if (job.name === JobName.StripeWebhookReplay) { + if (!ctx) { + workerLogger.error("No context found for stripe webhook replay job"); + return; + } + await runStripeWebhookReplay({ + ctx, + payload: job.data, + }); + return; + } + if (job.name === JobName.GenerateFeatureDisplay) { if (!ctx) { workerLogger.error("No context found for generate feature display job"); @@ -202,6 +261,16 @@ export const processMessage = async ({ return; } + if (job.name === JobName.SyncCustomerDirty) { + if (!ctx) { + workerLogger.error("No context found for sync customer dirty job"); + return; + } + + await syncItemV5({ ctx, payload: job.data }); + return; + } + if (job.name === JobName.Track) { if (!ctx) { workerLogger.error("No context found for track job"); diff --git a/server/src/queue/queueUtils.ts b/server/src/queue/queueUtils.ts index dab1cc43e4..905be7fae4 100644 --- a/server/src/queue/queueUtils.ts +++ b/server/src/queue/queueUtils.ts @@ -11,6 +11,9 @@ import { SendMessageCommand, } from "@aws-sdk/client-sqs"; import { generateId } from "@server/utils/genUtils"; +import type { StripeWebhookReplayPayload } from "@/external/stripe/webhookReplay/runStripeWebhookReplay.js"; +import type { BatchResetCustomerEntitlementsV2Payload } from "@/internal/balances/batchReset/types.js"; +import type { CustomerCreationRecoveryPayload } from "@/internal/customers/recovery/customerCreationRecoveryTypes.js"; import type { ClearCreditSystemCachePayload } from "@/internal/features/featureActions/runClearCreditSystemCacheTask.js"; import type { GenerateFeatureDisplayPayload } from "@/internal/features/workflows/generateFeatureDisplay.js"; import { getSqsClient } from "./initSqs.js"; @@ -79,10 +82,21 @@ export interface Payloads { apiVersion: ApiVersion; body: TrackParams; }; + [JobName.SyncCustomerDirty]: { + customerId: string; + orgId: string; + env: AppEnv; + region?: string; + redisInstance: string; + timestamp: number; + }; + [JobName.CustomerCreationRecovery]: CustomerCreationRecoveryPayload; + [JobName.StripeWebhookReplay]: StripeWebhookReplayPayload; [JobName.ClearCreditSystemCustomerCache]: ClearCreditSystemCachePayload; [JobName.GenerateFeatureDisplay]: GenerateFeatureDisplayPayload; [JobName.SendProductsUpdated]: SendProductsUpdatedPayload; [JobName.BatchResetCusEnts]: BatchResetCusEntsPayload; + [JobName.BatchResetCustomerEntitlementsV2]: BatchResetCustomerEntitlementsV2Payload; [JobName.AutoTopUp]: { orgId: string; env: AppEnv; diff --git a/server/src/queue/workflows.ts b/server/src/queue/workflows.ts index 5e41eff254..85ab39a12f 100644 --- a/server/src/queue/workflows.ts +++ b/server/src/queue/workflows.ts @@ -1,6 +1,7 @@ import type { AppEnv } from "@autumn/shared"; import { logger } from "better-auth"; import { createSchedule } from "@/external/aws/eventbridge/eventBridgeUtils.js"; +import { isBatchResetEnabled } from "@/internal/misc/batchReset/batchResetConfigStore.js"; import { generateId } from "@/utils/genUtils.js"; import { JobName } from "./JobName.js"; import { addTaskToQueue } from "./queueUtils.js"; @@ -231,7 +232,10 @@ export const workflows = { triggerBatchResetCusEnts: ( payload: BatchResetCusEntsPayload, options?: TriggerOptions, - ) => triggerWorkflow({ name: "batchResetCusEnts", payload, options }), + ) => + isBatchResetEnabled() + ? triggerWorkflow({ name: "batchResetCusEnts", payload, options }) + : Promise.resolve(), triggerAutoTopUp: (payload: AutoTopUpPayload, options?: TriggerOptions) => triggerWorkflow({ name: "autoTopUp", payload, options }), diff --git a/server/src/trigger/migrations/migrationTaskPayload.ts b/server/src/trigger/migrations/migrationTaskPayload.ts new file mode 100644 index 0000000000..9775f1e8d3 --- /dev/null +++ b/server/src/trigger/migrations/migrationTaskPayload.ts @@ -0,0 +1,54 @@ +import { AppEnv } from "@autumn/shared"; +import { MigrationFilterSchema } from "@autumn/shared/api/migrations/filters/migrationFilter.js"; +import { OperationsSchema } from "@autumn/shared/api/migrations/operations/operations.js"; +import { z } from "zod/v4"; +import { PreparedStateSchema } from "@/internal/migrations/v2/prepare/types/index.js"; +import { RETRYABLE_MIGRATION_ITEM_RUN_STATUSES } from "@/internal/migrations/v2/run/utils/retryItemStatuses.js"; + +const ControlsSchema = z + .object({ + limit: z.number().int().min(1).optional(), + only: z.array(z.string()).optional(), + retryItemStatuses: z + .array(z.enum(RETRYABLE_MIGRATION_ITEM_RUN_STATUSES)) + .optional(), + }) + .optional(); + +export const RunMigrationPayloadSchema = z.object({ + orgId: z.string(), + env: z.enum(AppEnv), + migrationId: z.string(), + migrationRunId: z.string(), + dryRun: z.boolean().default(false), + lazyRun: z.boolean().default(false), + controls: ControlsSchema, +}); + +export type RunMigrationPayload = z.infer; + +export const PreparedMigrationSnapshotSchema = z.object({ + internal_id: z.string(), + id: z.string(), + org_id: z.string(), + env: z.enum(AppEnv), + filter: MigrationFilterSchema.nullable(), + operations: OperationsSchema.nullable(), + prepared_state: PreparedStateSchema, + no_billing_changes: z.boolean().nullable(), + retry_failed: z.boolean(), + archived: z.boolean(), + created_at: z.number(), + updated_at: z.number().nullable(), + event_internal_id: z.string(), +}); + +export const RunMigrationChunkPayloadSchema = RunMigrationPayloadSchema.extend({ + chunkIndex: z.number().int().min(0), + cursor: z.string().optional(), + migration: PreparedMigrationSnapshotSchema, +}); + +export type RunMigrationChunkPayload = z.infer< + typeof RunMigrationChunkPayloadSchema +>; diff --git a/server/src/trigger/migrations/migrationTaskQueue.ts b/server/src/trigger/migrations/migrationTaskQueue.ts new file mode 100644 index 0000000000..d41b63a702 --- /dev/null +++ b/server/src/trigger/migrations/migrationTaskQueue.ts @@ -0,0 +1,30 @@ +import { queue } from "@trigger.dev/sdk/v3"; +import type { MigrationRunScheduler } from "@/internal/migrations/v2/run/types/migrationRunScheduler.js"; + +export const MIGRATION_TASK_QUEUE_NAME = "migration-customer-work"; +export const MIGRATION_TASK_QUEUE_CONCURRENCY = 1; +export const MIGRATION_RUN_CUSTOMER_CONCURRENCY = 1; +export const MIGRATION_CHUNK_FETCH_SIZE = 100; +export const MIGRATION_SLICE_DURATION_MS = 10_000; +export const MIGRATION_CHUNK_MAX_DURATION_SECONDS = 15 * 60; +export const MIGRATION_LAZY_TASK_PRIORITY_SECONDS = 5 * 60; +// Interrupted item claims cannot yet be recovered safely without operator intent. +export const MIGRATION_TASK_RETRY = { maxAttempts: 1 } as const; + +export const migrationTaskQueue = queue({ + name: MIGRATION_TASK_QUEUE_NAME, + concurrencyLimit: MIGRATION_TASK_QUEUE_CONCURRENCY, +}); + +export const getMigrationTriggerOptions = ({ isDev }: { isDev: boolean }) => + isDev ? { region: "eu-central-1" as const } : {}; + +export const createMigrationChunkScheduler = ({ + now = Date.now, +}: { + now?: () => number; +} = {}): MigrationRunScheduler => ({ + batchSize: MIGRATION_CHUNK_FETCH_SIZE, + sliceDurationMs: MIGRATION_SLICE_DURATION_MS, + now, +}); diff --git a/server/src/trigger/migrations/runMigrationChunkTask.ts b/server/src/trigger/migrations/runMigrationChunkTask.ts new file mode 100644 index 0000000000..f83f11696b --- /dev/null +++ b/server/src/trigger/migrations/runMigrationChunkTask.ts @@ -0,0 +1,109 @@ +import { task } from "@trigger.dev/sdk/v3"; +import type { Logger } from "@/external/logtail/logtailUtils.js"; +import { warmupRegionalRedis } from "@/external/redis/initUtils/redisWarmup.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { + type RunMigrationResult, + runPreparedMigration, +} from "@/internal/migrations/v2/run/runMigration.js"; +import { isMigrationCancelRequested } from "@/internal/migrations/v2/run/utils/migrationCancelToken.js"; +import { + type RunMigrationChunkPayload, + RunMigrationChunkPayloadSchema, +} from "@/trigger/migrations/migrationTaskPayload.js"; +import { + createMigrationChunkScheduler, + MIGRATION_CHUNK_MAX_DURATION_SECONDS, + MIGRATION_RUN_CUSTOMER_CONCURRENCY, + MIGRATION_TASK_RETRY, + migrationTaskQueue, +} from "@/trigger/migrations/migrationTaskQueue.js"; +import { createTriggerContext } from "@/trigger/utils/createTriggerContext.js"; + +export const executeRunMigrationChunk = async ({ + ctx, + logger, + payload, +}: { + ctx: AutumnContext; + logger: Logger; + payload: RunMigrationChunkPayload; +}): Promise => { + await warmupRegionalRedis().catch((error) => { + logger.warn("run-migration-chunk: redis warmup failed (continuing)", { + data: { + error: error instanceof Error ? error.message : String(error), + }, + }); + }); + + if ( + await isMigrationCancelRequested({ migrationRunId: payload.migrationRunId }) + ) { + return { + processed: 0, + completion: "stopped", + cursor: payload.cursor ?? null, + }; + } + + if ( + payload.migration.id !== payload.migrationId || + payload.migration.org_id !== payload.orgId || + payload.migration.env !== payload.env + ) { + throw new Error("Migration chunk snapshot identity does not match payload"); + } + + logger.info("run-migration-chunk: starting", { + data: { + migrationRunId: payload.migrationRunId, + chunkIndex: payload.chunkIndex, + limit: payload.controls?.limit, + }, + }); + + const result = await runPreparedMigration({ + ctx, + migration: payload.migration, + migrationRunId: payload.migrationRunId, + dryRun: payload.dryRun, + controls: { + ...(payload.controls ?? {}), + concurrency: MIGRATION_RUN_CUSTOMER_CONCURRENCY, + checkpointDryRun: true, + }, + scheduler: createMigrationChunkScheduler(), + includeFilterCount: false, + afterInternalId: payload.cursor, + }); + + logger.info("run-migration-chunk: done", { + data: { + migrationRunId: payload.migrationRunId, + chunkIndex: payload.chunkIndex, + processed: result.processed, + completion: result.completion, + }, + }); + + return result; +}; + +export const runMigrationChunkTask = task({ + id: "run-migration-chunk", + queue: migrationTaskQueue, + retry: MIGRATION_TASK_RETRY, + machine: "medium-1x", + maxDuration: MIGRATION_CHUNK_MAX_DURATION_SECONDS, + run: async (rawPayload: unknown, { ctx: triggerCtx }) => { + const payload = RunMigrationChunkPayloadSchema.parse(rawPayload); + const { ctx, logger } = await createTriggerContext({ + orgId: payload.orgId, + env: payload.env, + triggerCtx, + }); + + return executeRunMigrationChunk({ ctx, logger, payload }); + }, +}); diff --git a/server/src/trigger/migrations/runMigrationCustomerTask.ts b/server/src/trigger/migrations/runMigrationCustomerTask.ts index acab142d5a..a8dc9d0e53 100644 --- a/server/src/trigger/migrations/runMigrationCustomerTask.ts +++ b/server/src/trigger/migrations/runMigrationCustomerTask.ts @@ -9,6 +9,7 @@ import { withMigrationItemTracking } from "@/internal/migrations/v2/actions/migr import { migrationRepo } from "@/internal/migrations/v2/repos/index.js"; import { migrateCustomer } from "@/internal/migrations/v2/run/migrateCustomer/index.js"; import { isMigrationCancelRequested } from "@/internal/migrations/v2/run/utils/migrationCancelToken.js"; +import { migrationTaskQueue } from "@/trigger/migrations/migrationTaskQueue.js"; import { createTriggerContext } from "@/trigger/utils/createTriggerContext.js"; const LAZY_MIGRATION_CUSTOMER_LOCK_MAX_WAIT_MS = 2 * 60 * 1000; @@ -112,17 +113,10 @@ export const executeRunMigrationCustomer = async ({ }); }; -/** - * Per-customer lazy migration task. Enqueued by `checkPendingMigrationsForCustomer` - * on the customer-fetch path. Claims the `migration_item_runs` row, busts the - * customer cache, then runs `migrateCustomer` under the existing tracking machinery. - * - * Trigger.dev's `concurrencyKey` (set at enqueue time) serializes parallel - * requests for the same customer + migration; the server-side claim is the - * real authority if another worker raced ahead. - */ +/** Lazy customer work shares fleet capacity; the item claim remains the duplicate authority. */ export const runMigrationCustomerTask = task({ id: "run-migration-customer", + queue: migrationTaskQueue, maxDuration: 600, run: async (rawPayload: unknown, { ctx: triggerCtx }) => { const payload = PayloadSchema.parse(rawPayload); diff --git a/server/src/trigger/migrations/runMigrationInChunks.ts b/server/src/trigger/migrations/runMigrationInChunks.ts new file mode 100644 index 0000000000..bdca100ea0 --- /dev/null +++ b/server/src/trigger/migrations/runMigrationInChunks.ts @@ -0,0 +1,63 @@ +import type { IterateScopeCompletion } from "@/internal/migrations/v2/run/orchestrators/iterateScope.js"; + +export type MigrationChunkResult = { + processed: number; + completion: IterateScopeCompletion; + cursor: string | null; +}; + +export type MigrationChunkRunResult = { + processed: number; + chunks: number; + canceled: boolean; +}; + +export const runMigrationInChunks = async ({ + limit, + isCancelRequested, + runChunk, +}: { + limit?: number; + isCancelRequested: () => Promise; + runChunk: (args: { + limit: number | undefined; + chunkIndex: number; + cursor: string | undefined; + }) => Promise; +}): Promise => { + let processed = 0; + let chunks = 0; + let cursor: string | undefined; + + while (limit === undefined || processed < limit) { + if (await isCancelRequested()) { + return { processed, chunks, canceled: true }; + } + + const remainingLimit = + limit === undefined ? undefined : Math.max(0, limit - processed); + const chunk = await runChunk({ + limit: remainingLimit, + chunkIndex: chunks, + cursor, + }); + chunks++; + processed += chunk.processed; + + if (chunk.completion === "stopped") { + return { processed, chunks, canceled: true }; + } + if (chunk.completion === "exhausted") break; + if (chunk.processed === 0) { + throw new Error("Migration chunk made no progress before continuation"); + } + if (!chunk.cursor) { + throw new Error( + "Migration chunk did not return a cursor for continuation", + ); + } + cursor = chunk.cursor; + } + + return { processed, chunks, canceled: false }; +}; diff --git a/server/src/trigger/migrations/runMigrationTask.ts b/server/src/trigger/migrations/runMigrationTask.ts index 47a24ec3a4..37c3d7e994 100644 --- a/server/src/trigger/migrations/runMigrationTask.ts +++ b/server/src/trigger/migrations/runMigrationTask.ts @@ -1,65 +1,55 @@ -import { AppEnv } from "@autumn/shared"; import { task } from "@trigger.dev/sdk/v3"; -import { z } from "zod/v4"; import type { Logger } from "@/external/logtail/logtailUtils.js"; import { warmupRegionalRedis } from "@/external/redis/initUtils/redisWarmup.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { withMigrationRunTracking } from "@/internal/migrations/v2/actions/migrationRun/index.js"; import { migrationRepo } from "@/internal/migrations/v2/repos/index.js"; -import { runMigration } from "@/internal/migrations/v2/run/runMigration.js"; -import { RETRYABLE_MIGRATION_ITEM_RUN_STATUSES } from "@/internal/migrations/v2/run/utils/retryItemStatuses.js"; +import { prepareMigration } from "@/internal/migrations/v2/run/runMigration.js"; +import { isMigrationCancelRequested } from "@/internal/migrations/v2/run/utils/migrationCancelToken.js"; import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js"; +import { + PreparedMigrationSnapshotSchema, + type RunMigrationChunkPayload, + RunMigrationPayloadSchema, + type RunMigrationPayload as RunMigrationPayloadType, +} from "@/trigger/migrations/migrationTaskPayload.js"; +import { + MIGRATION_RUN_CUSTOMER_CONCURRENCY, + MIGRATION_TASK_RETRY, +} from "@/trigger/migrations/migrationTaskQueue.js"; +import { + executeRunMigrationChunk, + runMigrationChunkTask, +} from "@/trigger/migrations/runMigrationChunkTask.js"; +import { + type MigrationChunkResult, + runMigrationInChunks, +} from "@/trigger/migrations/runMigrationInChunks.js"; import { createTriggerContext } from "@/trigger/utils/createTriggerContext.js"; -const DEFAULT_CONCURRENCY = 5; -// Admin (superuser) runs may exceed the default cap — enforced in handleRunMigration. -const MAX_CONCURRENCY = 100; +export type RunMigrationPayload = RunMigrationPayloadType; -const ControlsSchema = z - .object({ - limit: z.number().int().min(1).optional(), - only: z.array(z.string()).optional(), - concurrency: z.number().int().min(1).max(MAX_CONCURRENCY).optional(), - retryItemStatuses: z - .array(z.enum(RETRYABLE_MIGRATION_ITEM_RUN_STATUSES)) - .optional(), - }) - .optional(); - -const PayloadSchema = z.object({ - orgId: z.string(), - env: z.enum(AppEnv), - migrationId: z.string(), - migrationRunId: z.string(), - dryRun: z.boolean().default(false), - lazyRun: z.boolean().default(false), - controls: ControlsSchema, -}); - -export type RunMigrationPayload = z.infer; - -export const runMigrationTaskQueue = { - concurrencyLimit: 1, -}; +export type RunMigrationChunkRunner = ( + payload: RunMigrationChunkPayload, +) => Promise; /** Shared workload for the trigger.dev task and the local inline fallback. */ export const executeRunMigration = async ({ ctx, logger, payload, + runChunk, }: { ctx: AutumnContext; logger: Logger; payload: RunMigrationPayload; + runChunk?: RunMigrationChunkRunner; }) => { const { orgId, env, migrationId, migrationRunId, dryRun, lazyRun, controls } = payload; - // Trigger.dev tasks start with cold Redis connections — wait for - // readiness before touching the migration so cache invalidations - // (deleteCachedFullCustomer, invalidateSharedBalanceFields, - // invalidateCachedFullSubject) actually fire instead of being - // short-circuited by the `not_ready` availability gate. + // Trigger tasks start with cold Redis clients; warm them before preparation + // and cache work so availability checks do not short-circuit. await warmupRegionalRedis().catch((error) => { logger.warn("run-migration: redis warmup failed (continuing)", { data: { @@ -76,7 +66,6 @@ export const executeRunMigration = async ({ only: controls?.only, onlyCount: controls?.only?.length, limit: controls?.limit, - concurrency: controls?.concurrency, retryItemStatuses: controls?.retryItemStatuses, }, }); @@ -87,27 +76,45 @@ export const executeRunMigration = async ({ migrationRunId, run: async () => { const migration = await migrationRepo.find({ ctx, id: migrationId }); - - const effectiveControls = { - ...(controls ?? {}), - concurrency: controls?.concurrency ?? DEFAULT_CONCURRENCY, - }; + const preparedMigration = await prepareMigration({ + ctx, + migration, + dryRun, + }); + const migrationSnapshot = + PreparedMigrationSnapshotSchema.parse(preparedMigration); + const executeChunk: RunMigrationChunkRunner = + runChunk ?? + ((chunkPayload) => + executeRunMigrationChunk({ ctx, logger, payload: chunkPayload })); logger.info("run-migration: resolved controls", { data: { migrationRunId, noBillingChanges: migration.no_billing_changes === true, - concurrency: effectiveControls.concurrency, - concurrencyExplicit: controls?.concurrency !== undefined, + concurrency: MIGRATION_RUN_CUSTOMER_CONCURRENCY, }, }); - await runMigration({ - ctx, - migration, - dryRun, - migrationRunId, - controls: effectiveControls, + const chunkRun = await runMigrationInChunks({ + limit: controls?.limit, + isCancelRequested: () => + isMigrationCancelRequested({ migrationRunId }), + runChunk: ({ limit, chunkIndex, cursor }) => + executeChunk({ + ...payload, + chunkIndex, + cursor, + migration: migrationSnapshot, + controls: { + ...(controls ?? {}), + ...(limit === undefined ? {} : { limit }), + }, + }), + }); + + logger.info("run-migration: chunks complete", { + data: { migrationRunId, ...chunkRun }, }); }, }); @@ -132,12 +139,12 @@ export const executeRunMigration = async ({ export const runMigrationTask = task({ id: "run-migration", - queue: runMigrationTaskQueue, + retry: MIGRATION_TASK_RETRY, machine: "medium-1x", // Trigger.dev has no true "disable" — set very high to effectively remove the timeout. maxDuration: 86400, run: async (rawPayload: unknown, { ctx: triggerCtx }) => { - const payload = PayloadSchema.parse(rawPayload); + const payload = RunMigrationPayloadSchema.parse(rawPayload); const { ctx, logger } = await createTriggerContext({ orgId: payload.orgId, @@ -145,6 +152,17 @@ export const runMigrationTask = task({ triggerCtx, }); - await executeRunMigration({ ctx, logger, payload }); + await executeRunMigration({ + ctx, + logger, + payload, + runChunk: (chunkPayload) => + runMigrationChunkTask + .triggerAndWait(chunkPayload, { + idempotencyKey: `migration-chunk:${chunkPayload.migrationRunId}:${chunkPayload.chunkIndex}`, + idempotencyKeyTTL: "7d", + }) + .unwrap(), + }); }, }); diff --git a/server/src/trigger/utils/shouldRunTriggerTasksInline.ts b/server/src/trigger/utils/shouldRunTriggerTasksInline.ts new file mode 100644 index 0000000000..ffe003d531 --- /dev/null +++ b/server/src/trigger/utils/shouldRunTriggerTasksInline.ts @@ -0,0 +1,12 @@ +/** THE toggle for running Trigger.dev tasks in-process on `bun tw` swarm VMs, + * which have no Trigger runner. Flip to false once Trigger is wired into tw. */ +const RUN_TRIGGER_TASKS_INLINE_IN_TW = true; + +// Gated on TW_WORKER_MODE so a forgotten prod key fails loudly instead of +// silently dropping tasks off the durable layer. +export const shouldRunTriggerTasksInline = () => + RUN_TRIGGER_TASKS_INLINE_IN_TW && + process.env.TW_WORKER_MODE === "1" && + process.env.NODE_ENV !== "production" && + !process.env.TRIGGER_SERVER_SECRET_KEY && + !process.env.TRIGGER_SECRET_KEY; diff --git a/server/src/workers.ts b/server/src/workers.ts index bd0f9c67ab..afadd030e9 100644 --- a/server/src/workers.ts +++ b/server/src/workers.ts @@ -7,11 +7,14 @@ import { startAllEdgeConfigPolling, stopAllEdgeConfigPolling, } from "./internal/misc/edgeConfig/edgeConfigRegistry.js"; +import "./internal/misc/miscellaneousEdgeConfig/miscellaneousEdgeConfigStore.js"; import "./internal/misc/requestBlocks/requestBlockStore.js"; import "./internal/misc/rollouts/rolloutConfigStore.js"; import "./internal/misc/redisV2Cache/redisV2CacheStore.js"; +import "./internal/misc/mainRedisCache/mainRedisCacheStore.js"; import "./internal/misc/cacheV2Ramp/cacheV2RampStore.js"; import "./internal/misc/jobQueues/jobQueueStore.js"; +import "./internal/misc/batchReset/batchResetConfigStore.js"; // Number of worker processes (defaults to CPU cores) const NUM_PROCESSES = process.env.NODE_ENV === "development" ? 3 : 4; diff --git a/server/tests/_groups/core/coreBalances.ts b/server/tests/_groups/core/coreBalances.ts index 1afa0c5e07..f54ae1c341 100644 --- a/server/tests/_groups/core/coreBalances.ts +++ b/server/tests/_groups/core/coreBalances.ts @@ -12,6 +12,7 @@ export const coreBalances: TestGroup = { "balances/check/misc", "integration/balances/check/spend-limit/check-entity-product-spend-limit.test.ts", "integration/balances/check/spend-limit/check-customer-spend-limit.test.ts", + "integration/balances/check/pooled-balances", // ── Track ── "balances/track/basic", @@ -26,6 +27,9 @@ export const coreBalances: TestGroup = { "integration/balances/track/track-misc.test.ts", "integration/balances/track/spend-limit/track-entity-product-spend-limit.test.ts", "integration/balances/track/spend-limit/track-customer-spend-limit.test.ts", + "integration/balances/track/pooled-balances", + "integration/balances/track/spend-limit/pooled-balances", + "integration/balances/track/usage-alerts/pooled-balances", // ── Lock ── "integration/balances/lock/basic", @@ -39,5 +43,6 @@ export const coreBalances: TestGroup = { // ── Usage ── "integration/balances/update/usage/update-usage-basic.test.ts", "integration/balances/update/usage/update-usage-paid-allocated.test.ts", + "integration/balances/usage-windows/pooled-balances", ], }; diff --git a/server/tests/_groups/core/coreBatchReset.ts b/server/tests/_groups/core/coreBatchReset.ts new file mode 100644 index 0000000000..c0aaf0141e --- /dev/null +++ b/server/tests/_groups/core/coreBatchReset.ts @@ -0,0 +1,9 @@ +import type { TestGroup } from "../types"; + +export const coreBatchReset: TestGroup = { + name: "core-batch-reset", + description: + "V2 batch reset cron tests: scan eligibility, worker resets, skips, rollovers, cache invalidation", + tier: "core", + paths: ["integration/cron/batch-reset-v2"], +}; diff --git a/server/tests/_groups/core/coreBilling.ts b/server/tests/_groups/core/coreBilling.ts index b553a87a14..0d331f1844 100644 --- a/server/tests/_groups/core/coreBilling.ts +++ b/server/tests/_groups/core/coreBilling.ts @@ -1,6 +1,7 @@ import type { TestGroup } from "../types"; import { coreAttach } from "./coreAttach"; import { coreBackSync } from "./coreBackSync"; +import { coreBatchReset } from "./coreBatchReset"; import { coreBillingOthers } from "./coreBillingOthers"; import { coreLegacy } from "./coreLegacy"; import { coreLicensesBilling } from "./coreLicensesBilling"; @@ -28,5 +29,10 @@ export const coreBilling: TestGroup = { ...coreLicensesBilling.paths, ...coreLicensesCatalog.paths, ...corePlansCrud.paths, + + // ── Pooled balances ── + "integration/billing/pooled-balances", + "integration/billing/migrations-v2/update-plan-operation/pooled-balances", + ...coreBatchReset.paths, ], }; diff --git a/server/tests/_groups/core/coreUpdateSubscription.ts b/server/tests/_groups/core/coreUpdateSubscription.ts index 4ac2eda65d..3a90c26d93 100644 --- a/server/tests/_groups/core/coreUpdateSubscription.ts +++ b/server/tests/_groups/core/coreUpdateSubscription.ts @@ -17,6 +17,7 @@ export const coreUpdateSubscription: TestGroup = { "billing/update-subscription/custom-plan/update-free-to-paid.test.ts", "billing/update-subscription/custom-plan/update-paid-prepaid.test.ts", "billing/update-subscription/custom-plan/update-paid-features.test.ts", + "billing/update-subscription/custom-plan/update-paid-intervals.test.ts", "billing/update-subscription/free-trial/update-remove-trial.test.ts", "billing/update-subscription/free-trial/update-paid-trials.test.ts", "billing/update-subscription/free-trial/update-quantity-with-trial.test.ts", diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts index 909e83d0dd..a84745481c 100644 --- a/server/tests/_groups/temp.ts +++ b/server/tests/_groups/temp.ts @@ -1,24 +1,69 @@ import type { TestGroup } from "./types"; -// Still failing after retry on the 2026-07-20 rerun — under active triage. +// Still failing after retry — under active triage. +// +// NOTE: the 8 "Test filter had no matches" crashes from the same run are NOT +// listed here — those files only exist locally (branch is ahead of origin), and +// tw workers fast-forward to the origin-resolved sha. Push the branch to fix. const activeTempPaths = [ + // --- TRIGGER_SECRET_KEY: batch-transition task had no Trigger runner on tw. + // Fixed by shouldRunTriggerTasksInline; re-run to confirm. + "integration/licenses/billing/attach/attach-license-parent-stripe-product.test.ts", + "integration/licenses/billing/attach/attach-parent-customized-license-switch.test.ts", + "integration/licenses/billing/transitions/immediate-switch/attach-licenses-immediate-switch.test.ts", + "integration/licenses/billing/transitions/immediate-switch/base-price-transitions/attach-base-price-transitions.test.ts", + "integration/licenses/billing/transitions/immediate-switch/base-price-transitions/base-price-transition-edge-cases.test.ts", + "integration/licenses/billing/transitions/immediate-switch/base-price-transitions/update-base-price-transitions.test.ts", + "integration/licenses/billing/transitions/immediate-switch/item-transitions/free-item-transitions.test.ts", + "integration/licenses/billing/transitions/immediate-switch/item-transitions/non-consumable-item-transition.test.ts", + "integration/licenses/billing/transitions/immediate-switch/seat-customer-product-transition.test.ts", + "integration/licenses/billing/update/update-license-customize-patch.test.ts", + "integration/licenses/billing/update/update-license-version.test.ts", + + // --- "Can't have two fixed prices with the same interval" + "integration/licenses/billing/update/update-preserves-license-items.test.ts", + + // --- validateProductItems.ts:470 throws + "integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-scheduled-patch.test.ts", + "integration/billing/migrations-v2/update-plan-operation/state-preservation/subscriptions/update-plan-op-states.test.ts", + + // --- browser/Playwright: payment-element iframe locator matched 2 elements + "integration/billing/attach/invoice/attach-invoice-draft-deferred.test.ts", + + // --- licenses: remaining assertion failures + "integration/licenses/billing/transitions/scheduled-switch/base-price-transitions/scheduled-base-price-transition.test.ts", + "integration/licenses/billing/transitions/scheduled-switch/item-transitions/non-consumable-item-transition.test.ts", + "integration/licenses/billing/transitions/scheduled-switch/item-transitions/reset-cycle-anchor-transition.test.ts", + "integration/licenses/catalog-update/child-plan/catalog/child-and-parent-update.test.ts", + "integration/licenses/catalog-update/child-plan/propagate-base-license-edge-cases.test.ts", + "integration/licenses/catalog-update/child-plan/propagate/multiple-parent-versions.test.ts", "integration/licenses/catalog-update/license-catalog-edit-guards.test.ts", "integration/licenses/catalog-update/license-catalog-response.test.ts", - "integration/licenses/catalog-update/license-sandbox-copy.test.ts", - "integration/licenses/billing/checkout/stripe-checkout-license-quantity.test.ts", - "integration/crud/customers/get-customer-entity-rollover-granted.test.ts", - "integration/crud/plans/create-plan-basic.test.ts", - "integration/crud/plans/variants/lifecycle.test.ts", - "integration/crud/plans/variants/interval-family.test.ts", - "integration/crud/plans/variants/reset-tier-ladder.test.ts", - "integration/balances/lock/check-with-lock-credit-system.test.ts", - "integration/balances/track/basic/track-tokens.test.ts", - "integration/billing/update-subscription/custom-plan/update-paid-prepaid.test.ts", + + // --- track: deductions not visible / timeouts + "integration/balances/track/basic/track-deductions.test.ts", + "integration/balances/track/basic/track-event-name.test.ts", + "integration/balances/track/basic/track-paid-features.test.ts", + + // --- billing + "integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode.test.ts", + "integration/billing/legacy/attach/invoice/payment-failure/legacy-attach-payment-failed.test.ts", + "integration/billing/legacy/attach/new/legacy-new-oneoff-zero-decimal.test.ts", + "integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-price.test.ts", + "integration/billing/setup-payment/setup-payment-with-plan.test.ts", + "integration/billing/stripe-webhooks/subscription-created/licenses/sub-created-license-variant-backsync.test.ts", + "integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts", + "integration/billing/update-subscription/custom-plan/update-paid-features.test.ts", + "integration/billing/update-subscription/custom-plan/update-paid-intervals.test.ts", + + // --- crud + "integration/crud/plans/diffing/diffPlanV1.test.ts", + "integration/crud/plans/versioning/migration-drafts/update-plan-migration-drafts.test.ts", ]; export const temp: TestGroup = { name: "temp", - description: "Still-failing suites from full-run retry — under triage", + description: "Still-failing suites from the low-concurrency rerun — triage", tier: "domain", paths: activeTempPaths, maxConcurrency: 2, diff --git a/server/tests/integration/admin/reset-job-config.test.ts b/server/tests/integration/admin/reset-job-config.test.ts new file mode 100644 index 0000000000..d4745163fc --- /dev/null +++ b/server/tests/integration/admin/reset-job-config.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { AppEnv, ErrCode } from "@autumn/shared"; +import { Hono } from "hono"; +import { errorMiddleware } from "@/honoMiddlewares/errorMiddleware.js"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; + +const mockState = { + config: { enabled: false, batchSize: 500 }, + status: { + healthy: true, + configured: true, + lastSuccessAt: "2026-07-23T10:00:00.000Z", + error: null as string | null, + }, + updateCalls: [] as unknown[], +}; + +mock.module("@/internal/misc/resetJob/resetJobStore.js", () => ({ + getResetJobConfigFromSource: async () => mockState.config, + getResetJobConfigStatus: () => mockState.status, + updateResetJobConfig: async ({ config }: { config: unknown }) => { + mockState.updateCalls.push(config); + }, +})); + +import { handleGetAdminResetJobConfig } from "@/internal/admin/handleGetAdminResetJobConfig.js"; +import { handleUpsertAdminResetJobConfig } from "@/internal/admin/handleUpsertAdminResetJobConfig.js"; + +const buildApp = () => { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("ctx", { + env: AppEnv.Sandbox, + org: { slug: "tests-org" }, + logger: { warn: () => undefined, error: () => undefined }, + } as unknown as HonoEnv["Variables"]["ctx"]); + await next(); + }); + app.get("/admin/reset-job-config", ...handleGetAdminResetJobConfig); + app.put("/admin/reset-job-config", ...handleUpsertAdminResetJobConfig); + app.onError(errorMiddleware); + return app; +}; + +describe("admin reset job config", () => { + beforeEach(() => { + mockState.config = { enabled: false, batchSize: 500 }; + mockState.status = { + healthy: true, + configured: true, + lastSuccessAt: "2026-07-23T10:00:00.000Z", + error: null, + }; + mockState.updateCalls = []; + }); + + test("GET returns config and source status", async () => { + const response = await buildApp().request( + "http://localhost/admin/reset-job-config", + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + enabled: false, + batchSize: 500, + configHealthy: true, + configConfigured: true, + lastSuccessAt: "2026-07-23T10:00:00.000Z", + error: null, + }); + }); + + test("PUT saves the config", async () => { + const response = await buildApp().request( + "http://localhost/admin/reset-job-config", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: true, batchSize: 1_000 }), + }, + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true }); + expect(mockState.updateCalls).toEqual([ + { enabled: true, batchSize: 1_000 }, + ]); + }); + + test("PUT defaults missing values", async () => { + const response = await buildApp().request( + "http://localhost/admin/reset-job-config", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }, + ); + + expect(response.status).toBe(200); + expect(mockState.updateCalls).toEqual([{ enabled: false, batchSize: 500 }]); + }); + + test("PUT rejects non-boolean values", async () => { + const response = await buildApp().request( + "http://localhost/admin/reset-job-config", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: "yes" }), + }, + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.code).toBe(ErrCode.InvalidInputs); + expect(mockState.updateCalls).toHaveLength(0); + }); + + test("PUT rejects an unsafe batch size", async () => { + const response = await buildApp().request( + "http://localhost/admin/reset-job-config", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: true, batchSize: 2_001 }), + }, + ); + + expect(response.status).toBe(400); + expect(mockState.updateCalls).toHaveLength(0); + }); +}); diff --git a/server/tests/integration/auth/list-organizations-deduplicates-memberships.test.ts b/server/tests/integration/auth/list-organizations-deduplicates-memberships.test.ts new file mode 100644 index 0000000000..a95a5e7ac0 --- /dev/null +++ b/server/tests/integration/auth/list-organizations-deduplicates-memberships.test.ts @@ -0,0 +1,43 @@ +/** + * TDD test for duplicate organizations in the OAuth consent selector. + * Red returns one organization per membership; green returns each organization once. + */ + +import { expect, test } from "bun:test"; +import { member } from "@autumn/shared"; +import defaultCtx from "@tests/utils/testInitUtils/createTestContext.js"; +import { + createDashboardSession, + dashboardGet, +} from "@tests/utils/testInitUtils/dashboardSession.js"; +import { initDrizzle } from "@/db/initDrizzle.js"; +import { generateId } from "@/utils/genUtils.js"; + +const { db } = initDrizzle(); + +test("organization list deduplicates multiple memberships for the same organization", async () => { + const session = await createDashboardSession(defaultCtx); + + try { + await db.insert(member).values({ + id: generateId("mem"), + organizationId: defaultCtx.org.id, + userId: session.userId, + role: "owner", + createdAt: new Date(), + }); + + const response = await dashboardGet>( + defaultCtx, + session, + "/api/auth/organization/list", + ); + + expect(response.status).toBe(200); + expect(response.data.filter((org) => org.id === defaultCtx.org.id)).toHaveLength( + 1, + ); + } finally { + await session.cleanup(); + } +}); diff --git a/server/tests/integration/balances/auto-topup/auto-topup-purchase-limit-count-runtime.test.ts b/server/tests/integration/balances/auto-topup/auto-topup-purchase-limit-count-runtime.test.ts new file mode 100644 index 0000000000..34ec28a32a --- /dev/null +++ b/server/tests/integration/balances/auto-topup/auto-topup-purchase-limit-count-runtime.test.ts @@ -0,0 +1,220 @@ +/** + * Integration coverage: writable purchase_limit.count mid-flow with real ATU. + * + * Flow under test: + * 1. ATU fires once under purchase_limit.limit = 2 + * 2. customers.update sets purchase_limit.count = 1 + * 3. Next drain triggers one more ATU (allowed) + * 4. Third drain is blocked by the purchase limit + * + * Green-success criteria: + * - After step 2, expanded purchase_limit.count is 1 + * - After step 3, balance reflects a second top-up and invoice count grows + * - After step 4, balance only drops (no top-up) and invoice count is unchanged + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + CustomerExpand, + PurchaseLimitInterval, +} from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; +import { makeAutoTopupConfig } from "./utils/makeAutoTopupConfig"; + +const AUTO_TOPUP_WAIT_MS = 20000; + +const getExpandedPurchaseLimit = async ({ + autumn, + customerId, +}: { + autumn: Awaited>["autumnV2_1"]; + customerId: string; +}) => { + const customer = await autumn.customers.get(customerId, { + expand: [CustomerExpand.AutoTopupsPurchaseLimit], + skip_cache: "true", + }); + return customer.billing_controls?.auto_topups?.[0]?.purchase_limit as + | { + interval: PurchaseLimitInterval | null; + interval_count: number | null; + limit: number | null; + count: number; + next_reset_at: number; + } + | undefined; +}; + +test.concurrent( + `${chalk.yellowBright("auto-topup purchase_limit.count: set mid-flow then block at limit")}`, + async () => { + const oneOffItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const oneOffProd = products.oneOffAddOn({ + id: "topup-pl-count-runtime", + items: [oneOffItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "auto-topup-pl-count-runtime", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 300 }], + }), + ], + }); + + await autumnV2_1.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 50, + quantity: 100, + purchaseLimit: { + interval: PurchaseLimitInterval.Month, + limit: 2, + }, + }), + }); + + // Round 1: 300 - 260 = 40 → ATU → 140 (purchase 1 / limit 2) + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 260, + }); + await timeout(AUTO_TOPUP_WAIT_MS); + + const afterFirst = await autumnV2_1.customers.get(customerId); + const expectedAfterFirst = new Decimal(300).sub(260).add(100).toNumber(); + expectBalanceCorrect({ + customer: afterFirst, + featureId: TestFeature.Messages, + remaining: expectedAfterFirst, + }); + + const limitAfterFirst = await getExpandedPurchaseLimit({ + autumn: autumnV2_1, + customerId, + }); + expect(limitAfterFirst).toMatchObject({ + interval: PurchaseLimitInterval.Month, + limit: 2, + count: 1, + }); + + // Mid-flow: write purchase_limit.count = 1 (runtime state, not JSONB) + await autumnV2_1.customers.update(customerId, { + billing_controls: { + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold: 50, + quantity: 100, + purchase_limit: { + interval: PurchaseLimitInterval.Month, + interval_count: 1, + limit: 2, + count: 1, + }, + }, + ], + }, + }); + + const limitAfterSet = await getExpandedPurchaseLimit({ + autumn: autumnV2_1, + customerId, + }); + expect(limitAfterSet).toMatchObject({ + limit: 2, + count: 1, + }); + expect(limitAfterSet?.next_reset_at).toBe(limitAfterFirst?.next_reset_at); + + // Round 2: 140 - 100 = 40 → ATU → 140 (purchase 2 / limit 2) + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }); + await timeout(AUTO_TOPUP_WAIT_MS); + + const afterSecond = await autumnV2_1.customers.get( + customerId, + ); + const expectedAfterSecond = new Decimal(140).sub(100).add(100).toNumber(); + expectBalanceCorrect({ + customer: afterSecond, + featureId: TestFeature.Messages, + remaining: expectedAfterSecond, + }); + + const limitAfterSecond = await getExpandedPurchaseLimit({ + autumn: autumnV2_1, + customerId, + }); + expect(limitAfterSecond).toMatchObject({ + limit: 2, + count: 2, + }); + + await expectCustomerInvoiceCorrect({ + customerId, + count: 3, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: oneOffProd.id, + }); + + // Round 3: 140 - 100 = 40 → blocked (already at limit) + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }); + await timeout(AUTO_TOPUP_WAIT_MS); + + const afterBlocked = await autumnV2_1.customers.get( + customerId, + ); + expectBalanceCorrect({ + customer: afterBlocked, + featureId: TestFeature.Messages, + remaining: 40, + }); + + const limitAfterBlocked = await getExpandedPurchaseLimit({ + autumn: autumnV2_1, + customerId, + }); + expect(limitAfterBlocked).toMatchObject({ + limit: 2, + count: 2, + }); + + await expectCustomerInvoiceCorrect({ + customerId, + count: 3, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: oneOffProd.id, + }); + }, +); diff --git a/server/tests/integration/balances/check/pooled-balances/check-pooled-balances.test.ts b/server/tests/integration/balances/check/pooled-balances/check-pooled-balances.test.ts new file mode 100644 index 0000000000..dfc0c229b8 --- /dev/null +++ b/server/tests/integration/balances/check/pooled-balances/check-pooled-balances.test.ts @@ -0,0 +1,198 @@ +// Contract: customer/get-or-create/entity/check expose one shared pool using pooled_balances.granted. +// Source entitlements stay private; the exact balance is allowed and one unit above is rejected. + +import { expect, test } from "bun:test"; +import type { + ApiCustomerV5, + ApiEntityV2, + CheckResponseV3, +} from "@autumn/shared"; +import { fullCustomerToCustomerEntitlements } from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService.js"; +import { getApiCustomer } from "@/internal/customers/cusUtils/apiCusUtils/getApiCustomer.js"; + +const CONTRIBUTION = 500; +const POOLED_GRANT = CONTRIBUTION * 2; + +const expectPooledCheck = ({ + response, + allowed, + requiredBalance, +}: { + response: CheckResponseV3; + allowed: boolean; + requiredBalance: number; +}) => { + expect(response).toMatchObject({ + allowed, + required_balance: requiredBalance, + balance: { + granted: POOLED_GRANT, + remaining: POOLED_GRANT, + usage: 0, + }, + }); + expect(response.balance?.breakdown).toHaveLength(1); + expect(response.balance?.breakdown?.[0]).toMatchObject({ + plan_id: null, + included_grant: POOLED_GRANT, + remaining: POOLED_GRANT, + usage: 0, + }); +}; + +test.concurrent( + `${chalk.yellowBright("pooled check: customer and entity reads expose one shared grant with exact boundary checks")}`, + async () => { + const pooledPlan = products.base({ + id: "pooled-check-shared", + items: [ + { + ...items.monthlyMessages({ includedUsage: CONTRIBUTION }), + pooled: true, + }, + ], + }); + const { autumnV2_2, ctx, customerId, entities } = await initScenario({ + customerId: "pooled-check-shared", + setup: [ + s.customer({ testClock: false }), + s.entities({ count: 3, featureId: TestFeature.Users }), + s.products({ list: [pooledPlan] }), + ], + actions: [ + s.billing.attach({ productId: pooledPlan.id, entityIndex: 0 }), + s.billing.attach({ productId: pooledPlan.id, entityIndex: 1 }), + ], + }); + + const cachedCustomer = + await autumnV2_2.customers.get(customerId); + const uncachedCustomer = await autumnV2_2.customers.get( + customerId, + { skip_cache: "true" }, + ); + const getOrCreateCustomer = (await autumnV2_2.post( + "/customers.get_or_create", + { customer_id: customerId }, + )) as ApiCustomerV5; + + for (const customer of [ + cachedCustomer, + uncachedCustomer, + getOrCreateCustomer, + ]) { + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + granted: POOLED_GRANT, + includedGrant: POOLED_GRANT, + remaining: POOLED_GRANT, + usage: 0, + planId: null, + breakdownCount: 1, + }); + } + + const legacyFullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + entityId: entities[0].id, + withEntities: true, + withSubs: true, + }); + if (!legacyFullCustomer) { + throw new Error(`Customer '${customerId}' was not found`); + } + const legacyDeductionCandidates = fullCustomerToCustomerEntitlements({ + fullCustomer: legacyFullCustomer, + featureId: TestFeature.Messages, + entity: legacyFullCustomer.entity, + }); + expect(legacyDeductionCandidates).toHaveLength(1); + expect(legacyDeductionCandidates[0]).toMatchObject({ + is_pooled_balance: true, + pooled_balance: { granted: POOLED_GRANT }, + }); + const legacyApiCustomer = await getApiCustomer({ + ctx, + fullCustomer: legacyFullCustomer, + withAutumnId: true, + }); + expectBalanceCorrect({ + customer: legacyApiCustomer, + featureId: TestFeature.Messages, + granted: POOLED_GRANT, + includedGrant: POOLED_GRANT, + remaining: POOLED_GRANT, + usage: 0, + planId: null, + breakdownCount: 1, + }); + + const contributingEntity = await autumnV2_2.entities.get( + customerId, + entities[0].id, + ); + const unassignedEntity = await autumnV2_2.entities.get( + customerId, + entities[2].id, + { skip_cache: "true" }, + ); + + for (const entity of [contributingEntity, unassignedEntity]) { + expectBalanceCorrect({ + customer: entity, + featureId: TestFeature.Messages, + granted: POOLED_GRANT, + includedGrant: POOLED_GRANT, + remaining: POOLED_GRANT, + usage: 0, + planId: null, + breakdownCount: 1, + }); + } + + const exactCustomerCheck = await autumnV2_2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: POOLED_GRANT, + }); + expectPooledCheck({ + response: exactCustomerCheck, + allowed: true, + requiredBalance: POOLED_GRANT, + }); + + const exactEntityCheck = await autumnV2_2.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + required_balance: POOLED_GRANT, + }); + expectPooledCheck({ + response: exactEntityCheck, + allowed: true, + requiredBalance: POOLED_GRANT, + }); + + const abovePoolCheck = await autumnV2_2.check({ + customer_id: customerId, + entity_id: entities[2].id, + feature_id: TestFeature.Messages, + required_balance: POOLED_GRANT + 1, + skip_cache: true, + }); + expectPooledCheck({ + response: abovePoolCheck, + allowed: false, + requiredBalance: POOLED_GRANT + 1, + }); + }, +); diff --git a/server/tests/integration/balances/sync/sync-balances-noop-update.test.ts b/server/tests/integration/balances/sync/sync-balances-noop-update.test.ts new file mode 100644 index 0000000000..c8eeca3008 --- /dev/null +++ b/server/tests/integration/balances/sync/sync-balances-noop-update.test.ts @@ -0,0 +1,224 @@ +import { expect, test } from "bun:test"; +import pg, { type PoolClient } from "pg"; + +type CustomerEntitlementSyncRow = { + id: string; + balance: string; + adjustment: string | null; + entities: Record | null; + next_reset_at: string | null; + cache_version: number | null; + tuple_id: string; +}; + +const createCustomerEntitlementFixture = async ({ + client, +}: { + client: PoolClient; +}) => { + const suffix = crypto.randomUUID(); + const organizationId = `org_sync_noop_${suffix}`; + const featureId = `feature_sync_noop_${suffix}`; + const internalFeatureId = `feat_sync_noop_${suffix}`; + const entitlementId = `ent_sync_noop_${suffix}`; + const customerEntitlementId = `ce_sync_noop_${suffix}`; + const createdAt = Date.now(); + + await client.query( + ` + INSERT INTO organizations (id, slug, name, "createdAt") + VALUES ($1, $2, 'Sync no-op test', NOW()) + `, + [organizationId, organizationId], + ); + await client.query( + ` + INSERT INTO features ( + internal_id, org_id, created_at, env, id, name, type, config + ) + VALUES ($1, $2, $3, 'sandbox', $4, 'Sync no-op feature', 'metered', '{}') + `, + [internalFeatureId, organizationId, createdAt, featureId], + ); + await client.query( + ` + INSERT INTO entitlements ( + id, created_at, internal_feature_id, allowance_type, allowance, + org_id, feature_id + ) + VALUES ($1, $2, $3, 'fixed', 100, $4, $5) + `, + [entitlementId, createdAt, internalFeatureId, organizationId, featureId], + ); + await client.query( + ` + INSERT INTO customer_entitlements ( + id, entitlement_id, internal_customer_id, internal_feature_id, + balance, created_at, adjustment, entities, cache_version, + customer_id, feature_id + ) + VALUES ($1, $2, $3, $4, 100, $5, NULL, '{"scope": 1}', 0, $6, $7) + `, + [ + customerEntitlementId, + entitlementId, + `customer_sync_noop_${suffix}`, + internalFeatureId, + createdAt, + `customer_sync_noop_${suffix}`, + featureId, + ], + ); + + return customerEntitlementId; +}; + +const readCustomerEntitlement = async ({ + client, + customerEntitlementId, +}: { + client: PoolClient; + customerEntitlementId: string; +}) => { + const result = await client.query( + ` + SELECT + id, + balance, + adjustment, + entities, + next_reset_at, + cache_version, + ctid::text AS tuple_id + FROM customer_entitlements + WHERE id = $1 + `, + [customerEntitlementId], + ); + + const customerEntitlement = result.rows[0]; + expect(customerEntitlement).toBeDefined(); + if (!customerEntitlement) { + throw new Error("Expected the customer entitlement fixture to exist"); + } + + return customerEntitlement; +}; + +const syncCustomerEntitlement = async ({ + client, + customerEntitlement, + balance, +}: { + client: PoolClient; + customerEntitlement: CustomerEntitlementSyncRow; + balance: string | number; +}) => { + const entities = customerEntitlement.entities; + await client.query("SELECT sync_balances_v2($1::jsonb)", [ + JSON.stringify({ + customer_entitlement_updates: [ + { + customer_entitlement_id: customerEntitlement.id, + balance, + adjustment: customerEntitlement.adjustment, + entities, + next_reset_at: customerEntitlement.next_reset_at, + entity_count: entities ? Object.keys(entities).length : 0, + cache_version: customerEntitlement.cache_version ?? 0, + }, + ], + rollover_updates: [], + usage_window_updates: [], + }), + ]); +}; + +test("sync_balances_v2 skips unchanged customer entitlement updates", async () => { + const pool = new pg.Pool({ + connectionString: process.env.DATABASE_URL, + max: 1, + }); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const customerEntitlementId = await createCustomerEntitlementFixture({ + client, + }); + const initialCustomerEntitlement = await readCustomerEntitlement({ + client, + customerEntitlementId, + }); + await syncCustomerEntitlement({ + client, + customerEntitlement: initialCustomerEntitlement, + balance: initialCustomerEntitlement.balance, + }); + + const unchangedCustomerEntitlement = await readCustomerEntitlement({ + client, + customerEntitlementId, + }); + expect(unchangedCustomerEntitlement.tuple_id).toBe( + initialCustomerEntitlement.tuple_id, + ); + + const changedBalance = Number(unchangedCustomerEntitlement.balance) - 1; + await syncCustomerEntitlement({ + client, + customerEntitlement: unchangedCustomerEntitlement, + balance: changedBalance, + }); + + const changedCustomerEntitlement = await readCustomerEntitlement({ + client, + customerEntitlementId, + }); + expect(Number(changedCustomerEntitlement.balance)).toBe(changedBalance); + expect(changedCustomerEntitlement.tuple_id).not.toBe( + unchangedCustomerEntitlement.tuple_id, + ); + + await syncCustomerEntitlement({ + client, + customerEntitlement: { + ...changedCustomerEntitlement, + adjustment: "1", + }, + balance: changedCustomerEntitlement.balance, + }); + + const adjustedCustomerEntitlement = await readCustomerEntitlement({ + client, + customerEntitlementId, + }); + expect(Number(adjustedCustomerEntitlement.adjustment)).toBe(1); + expect(adjustedCustomerEntitlement.tuple_id).not.toBe( + changedCustomerEntitlement.tuple_id, + ); + + await syncCustomerEntitlement({ + client, + customerEntitlement: { + ...adjustedCustomerEntitlement, + entities: { scope: 2 }, + }, + balance: adjustedCustomerEntitlement.balance, + }); + + const updatedEntitiesCustomerEntitlement = await readCustomerEntitlement({ + client, + customerEntitlementId, + }); + expect(updatedEntitiesCustomerEntitlement.entities).toEqual({ scope: 2 }); + expect(updatedEntitiesCustomerEntitlement.tuple_id).not.toBe( + adjustedCustomerEntitlement.tuple_id, + ); + } finally { + await client.query("ROLLBACK"); + client.release(); + await pool.end(); + } +}); diff --git a/server/tests/integration/balances/sync/sync-coalescing.test.ts b/server/tests/integration/balances/sync/sync-coalescing.test.ts new file mode 100644 index 0000000000..38f92d23f2 --- /dev/null +++ b/server/tests/integration/balances/sync/sync-coalescing.test.ts @@ -0,0 +1,243 @@ +/** + * TDD contract test for sync-v4 coalescing: per-customer Redis dirty state + * replaces full-payload SyncBalanceBatchV4 messages; SQS carries only a + * "customer X is dirty" signal, enqueued on the empty->dirty transition. + * + * Contract under test: + * New types/fields: + * - Redis keys on the v2-cache instance: + * sync:dirty:{orgId}:{env}:{customerId} (merged selectors + usage-window snapshots) + * sync:claim:{orgId}:{env}:{customerId} (exists only during a drain) + * sync:signal:{orgId}:{env}:{customerId} (TTL marker: signal outstanding) + * - gate: miscellaneousEdgeConfig.syncCoalesce (global switch, dark by + * default); tests enable per request via the x-sync-coalesce header + * (ctx.testOptions.syncCoalesce, non-prod only) + * New behaviors: + * - gate ON: track -> dirty key appears; after drain: dirty+claim+signal keys + * gone and Postgres balance equals Redis balance + * - gate ON: burst of N tracks -> Postgres balance exact (no lost writes, + * no double-applies) despite signal collapse + * - gate ON: usage-window customer -> usage_windows table holds the LATEST + * counter snapshot after drain (last-write-wins per feature) + * Side effects (unchanged): + * - customer_entitlements balance converges to Redis state + * + * Pre-impl red: every gate-ON assertion fails because no code writes + * sync:dirty:* keys (feature does not exist). + * Post-impl green: all assertions pass once the dirty-state producer + * (SyncBatchingManagerV3 flush target) + SyncCustomerDirty drain handler land. + */ + +import { expect, test } from "bun:test"; +import { ResetInterval } from "@autumn/shared"; +import chalk from "chalk"; +import { sql } from "drizzle-orm"; +import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; + +const INCLUDED_USAGE = 1_000_000; + +const dirtyKey = ({ + orgId, + env, + customerId, +}: { + orgId: string; + env: string; + customerId: string; +}) => `sync:dirty:${orgId}:${env}:${customerId}`; + +const claimKey = ({ + orgId, + env, + customerId, +}: { + orgId: string; + env: string; + customerId: string; +}) => `sync:claim:${orgId}:${env}:${customerId}`; + +const signalKey = ({ + orgId, + env, + customerId, +}: { + orgId: string; + env: string; + customerId: string; +}) => `sync:signal:${orgId}:${env}:${customerId}`; + +const pollUntil = async ({ + fn, + predicate, + timeoutMs = 30_000, + intervalMs = 500, +}: { + fn: () => Promise; + predicate: (value: T) => boolean; + timeoutMs?: number; + intervalMs?: number; +}): Promise => { + const startedAt = Date.now(); + let last: T = await fn(); + while (!predicate(last) && Date.now() - startedAt < timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + last = await fn(); + } + return last; +}; + +const pgBalance = async ({ + db, + customerId, +}: { + db: any; + customerId: string; +}): Promise => { + const rows = await db.execute( + sql`SELECT ce.balance + FROM customer_entitlements ce + JOIN customers c ON c.internal_id = ce.internal_customer_id + JOIN features f ON f.internal_id = ce.internal_feature_id + WHERE c.id = ${customerId} AND f.id = 'messages' + ORDER BY ce.created_at DESC LIMIT 1`, + ); + const row = rows[0] as { balance: number | string } | undefined; + return row ? Number(row.balance) : null; +}; + +// ── Contract 1+2: gate ON — dirty key lifecycle + exact burst convergence ── +test.concurrent( + `${chalk.yellowBright("sync-coalesce: dirty key lifecycle and burst convergence (gate on)")}`, + async () => { + const customerId = "sync-coalesce-burst"; + const product = products.base({ + id: "coalesce-free", + items: [items.monthlyMessages({ includedUsage: INCLUDED_USAGE })], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [product] })], + actions: [s.attach({ productId: product.id })], + }); + + const redis = resolveRedisV2(); + const keyScope = { + orgId: ctx.org.id, + env: ctx.env as string, + customerId, + }; + // Deterministic customer ids: clear coalescing keys left by prior runs. + await redis.del( + dirtyKey(keyScope), + claimKey(keyScope), + signalKey(keyScope), + ); + // Burst: 30 tracks with the gate enabled per request via testOptions. + const BURST = 30; + for (let i = 0; i < BURST; i++) { + await autumnV1.track( + { customer_id: customerId, feature_id: "messages", value: 1 }, + { headers: { "x-sync-coalesce": "true" } }, + ); + } + + // ── Assertion: dirty key appears while writes are in flight ────── + // Pre-impl: never appears (no producer writes it) -> red. + const dirtySeen = await pollUntil({ + fn: async () => await redis.exists(dirtyKey(keyScope)), + predicate: (exists) => exists === 1, + timeoutMs: 10_000, + }); + expect(dirtySeen).toBe(1); + + // ── Assertion: after drain, Postgres balance is exact ──────────── + const expected = INCLUDED_USAGE - BURST; + const converged = await pollUntil({ + fn: async () => await pgBalance({ db: ctx.db, customerId }), + predicate: (balance) => balance === expected, + timeoutMs: 45_000, + }); + expect(converged).toBe(expected); + + // ── Assertion: coalescing keys are cleaned up after drain ──────── + const leftovers = await pollUntil({ + fn: async () => + (await redis.exists(dirtyKey(keyScope))) + + (await redis.exists(claimKey(keyScope))), + predicate: (count) => count === 0, + timeoutMs: 30_000, + }); + expect(leftovers).toBe(0); + }, +); + +// ── Contract 3: gate ON — usage-window snapshots land last-write-wins ── +test.concurrent( + `${chalk.yellowBright("sync-coalesce: usage-window latest snapshot lands (gate on)")}`, + async () => { + const customerId = "sync-coalesce-uw"; + const product = products.base({ + id: "coalesce-uw-free", + items: [items.monthlyMessages({ includedUsage: INCLUDED_USAGE })], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [product] })], + actions: [s.attach({ productId: product.id })], + }); + + // Deterministic customer ids: clear coalescing keys left by prior runs. + await resolveRedisV2().del( + dirtyKey({ orgId: ctx.org.id, env: ctx.env as string, customerId }), + claimKey({ orgId: ctx.org.id, env: ctx.env as string, customerId }), + signalKey({ orgId: ctx.org.id, env: ctx.env as string, customerId }), + ); + + // Windowed usage cap -> every deduction carries a counter snapshot. + await autumnV1.customers.update(customerId, { + billing_controls: { + usage_limits: [ + { + feature_id: "messages", + enabled: true, + limit: INCLUDED_USAGE, + interval: ResetInterval.Day, + }, + ], + }, + }); + + const BURST = 20; + for (let i = 0; i < BURST; i++) { + await autumnV1.track( + { customer_id: customerId, feature_id: "messages", value: 1 }, + { headers: { "x-sync-coalesce": "true" } }, + ); + } + + // ── Assertion: usage_windows row reflects the FINAL counter ────── + // (last-write-wins: an older snapshot overwriting a newer one would + // show usage < BURST here) + const usage = await pollUntil({ + fn: async () => { + const rows = await ctx.db.execute( + sql`SELECT uw.usage + FROM usage_windows uw + JOIN customers c ON c.internal_id = uw.internal_customer_id + WHERE c.id = ${customerId} AND uw.feature_id = 'messages' + ORDER BY uw.updated_at DESC LIMIT 1`, + ); + const row = rows[0] as { usage: number | string } | undefined; + return row ? Number(row.usage) : null; + }, + predicate: (value) => value === BURST, + timeoutMs: 45_000, + }); + expect(usage).toBe(BURST); + }, +); diff --git a/server/tests/integration/balances/sync/sync-dirty-state.test.ts b/server/tests/integration/balances/sync/sync-dirty-state.test.ts new file mode 100644 index 0000000000..3112639425 --- /dev/null +++ b/server/tests/integration/balances/sync/sync-dirty-state.test.ts @@ -0,0 +1,302 @@ +/** + * TDD integration contract for the sync dirty-state primitives (sync-v4 coalescing). + * + * Contract under test: + * New modules (server/src/internal/balances/utils/sync/dirtyState/): + * - syncDirtyKeys.ts: buildSyncDirtyKeys({orgId, env, customerId}) + * -> { dirtyKey, claimKey, signalKey } + * - markSyncDirty.ts: markSyncDirty({redis, scope, cusEntIds, rolloverIds, + * modifiedCusEntIdsByFeatureId, usageWindowUpdates, entityId, + * signalTtlSeconds}) + * -> { shouldSignal: boolean } + * Behaviors: + * - first mark on empty state -> shouldSignal=true, signal marker set + * - second mark while signal marker live -> shouldSignal=false + * - selectors accumulate (set union) across marks + * - usage-window snapshots are last-write-wins per feature + * - claimSyncDirty.ts: claimSyncDirty({redis, scope}) + * -> merged dirty state or null when empty + * Behaviors: + * - moves dirty -> claim atomically; dirty key gone after claim + * - leftover claim (crashed worker) is MERGED with new dirty state + * on the next claim, never lost + * - clearSyncClaim({redis, scope, generation}) removes the owned claim + * Modified: + * - processMessage.shouldRetrySqsJobError: transient REDIS errors on the + * new signal job are retryable (message must stay in SQS) + * + * Pre-impl red: imports fail (modules do not exist). + * Post-impl green: all assertions pass against a real Redis (v2 cache instance). + */ + +import { expect, test } from "bun:test"; +import chalk from "chalk"; +import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js"; +import { + claimSyncDirty, + clearSyncClaim, +} from "@/internal/balances/utils/sync/dirtyState/claimSyncDirty.js"; +import { markSyncDirty } from "@/internal/balances/utils/sync/dirtyState/markSyncDirty.js"; +import { buildSyncDirtyKeys } from "@/internal/balances/utils/sync/dirtyState/syncDirtyKeys.js"; +import { JobName } from "@/queue/JobName.js"; +import { shouldRetrySqsJobError } from "@/queue/processMessage.js"; + +const scope = { + orgId: "org_dirtystate_unit", + env: "sandbox", + customerId: "unit-dirty-cus", +}; + +const wipe = async () => { + const redis = resolveRedisV2(); + const { dirtyKey, claimKey, signalKey } = buildSyncDirtyKeys(scope); + await redis.del(dirtyKey, claimKey, signalKey); + return redis; +}; + +test(`${chalk.yellowBright("dirty-state: mark signals only on empty->dirty transition")}`, async () => { + const redis = await wipe(); + + const first = await markSyncDirty({ + redis, + scope, + cusEntIds: ["ce_1"], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: { messages: ["ce_1"] }, + usageWindowUpdates: [], + signalTtlSeconds: 60, + }); + expect(first.shouldSignal).toBe(true); + + const second = await markSyncDirty({ + redis, + scope, + cusEntIds: ["ce_2"], + rolloverIds: ["ro_1"], + modifiedCusEntIdsByFeatureId: { messages: ["ce_2"] }, + usageWindowUpdates: [], + signalTtlSeconds: 60, + }); + expect(second.shouldSignal).toBe(false); +}); + +test(`${chalk.yellowBright("dirty-state: claim merges accumulated selectors and empties dirty")}`, async () => { + const redis = await wipe(); + + await markSyncDirty({ + redis, + scope, + cusEntIds: ["ce_1"], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: { messages: ["ce_1"] }, + usageWindowUpdates: [ + { + ts: 1, + update: { + internal_customer_id: "ic_1", + feature_id: "messages", + usage_windows: [{ usage: 1 }], + } as any, + }, + ], + signalTtlSeconds: 60, + }); + await markSyncDirty({ + redis, + scope, + cusEntIds: ["ce_2", "ce_1"], + rolloverIds: ["ro_1"], + modifiedCusEntIdsByFeatureId: { credits: ["ce_2"] }, + usageWindowUpdates: [ + { + ts: 2, + update: { + internal_customer_id: "ic_1", + feature_id: "messages", + usage_windows: [{ usage: 2 }], + } as any, + }, + ], + signalTtlSeconds: 60, + }); + + const claimed = await claimSyncDirty({ redis, scope }); + expect(claimed).not.toBeNull(); + // Set-union of selectors across both marks + expect(claimed!.cusEntIds.sort()).toEqual(["ce_1", "ce_2"]); + expect(claimed!.rolloverIds).toEqual(["ro_1"]); + expect(Object.keys(claimed!.modifiedCusEntIdsByFeatureId).sort()).toEqual([ + "credits", + "messages", + ]); + // Usage windows: LAST snapshot wins per feature + expect(claimed!.usageWindowUpdates).toHaveLength(1); + expect( + (claimed!.usageWindowUpdates[0] as any).usage_windows[0].usage, + ).toBe(2); + + // Dirty key is empty after claim + const { dirtyKey } = buildSyncDirtyKeys(scope); + expect(await redis.exists(dirtyKey)).toBe(0); + + await clearSyncClaim({ redis, scope, generation: claimed!.generation }); +}); + +test(`${chalk.yellowBright("dirty-state: leftover claim from crashed worker merges into next claim")}`, async () => { + const redis = await wipe(); + + // First cycle: mark + claim, then CRASH (claim never cleared). + await markSyncDirty({ + redis, + scope, + cusEntIds: ["ce_old"], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: { messages: ["ce_old"] }, + usageWindowUpdates: [], + signalTtlSeconds: 60, + }); + const firstClaim = await claimSyncDirty({ redis, scope }); + expect(firstClaim).not.toBeNull(); + // no clearSyncClaim: simulates a worker death mid-flush + + // New write arrives, then redelivery claims again. + await markSyncDirty({ + redis, + scope, + cusEntIds: ["ce_new"], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: { messages: ["ce_new"] }, + usageWindowUpdates: [], + signalTtlSeconds: 60, + }); + const secondClaim = await claimSyncDirty({ redis, scope }); + expect(secondClaim).not.toBeNull(); + // ce_old must survive the crash: merged, not lost + expect(secondClaim!.cusEntIds.sort()).toEqual(["ce_new", "ce_old"]); + + await clearSyncClaim({ redis, scope, generation: secondClaim!.generation }); +}); + +test(`${chalk.yellowBright("dirty-state: older usage-window snapshot does not overwrite newer")}`, async () => { + const redis = await wipe(); + + await markSyncDirty({ + redis, + scope, + cusEntIds: [], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: {}, + usageWindowUpdates: [ + { + ts: 2, + update: { + internal_customer_id: "ic_1", + feature_id: "messages", + usage_windows: [{ usage: 2 }], + } as any, + }, + ], + signalTtlSeconds: 60, + }); + await markSyncDirty({ + redis, + scope, + cusEntIds: [], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: {}, + usageWindowUpdates: [ + { + ts: 1, + update: { + internal_customer_id: "ic_1", + feature_id: "messages", + usage_windows: [{ usage: 1 }], + } as any, + }, + ], + signalTtlSeconds: 60, + }); + + const claimed = await claimSyncDirty({ redis, scope }); + expect( + (claimed!.usageWindowUpdates[0] as any).usage_windows[0].usage, + ).toBe(2); + await clearSyncClaim({ redis, scope, generation: claimed!.generation }); +}); + +test(`${chalk.yellowBright("dirty-state: stale generation cannot clear a newer claim")}`, async () => { + const redis = await wipe(); + + await markSyncDirty({ + redis, + scope, + cusEntIds: ["ce_1"], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: {}, + usageWindowUpdates: [], + signalTtlSeconds: 60, + }); + const firstClaim = await claimSyncDirty({ redis, scope }); + const secondClaim = await claimSyncDirty({ redis, scope }); + + await clearSyncClaim({ + redis, + scope, + generation: firstClaim!.generation, + }); + const { claimKey } = buildSyncDirtyKeys(scope); + expect(await redis.exists(claimKey)).toBe(1); + await clearSyncClaim({ + redis, + scope, + generation: secondClaim!.generation, + }); +}); + +test(`${chalk.yellowBright("dirty-state: colon-containing feature id roundtrips")}`, async () => { + const redis = await wipe(); + + await markSyncDirty({ + redis, + scope, + cusEntIds: [], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: { "messages:email": ["ce:1"] }, + usageWindowUpdates: [ + { + ts: 1, + update: { + internal_customer_id: "ic_1", + feature_id: "messages:email", + usage_windows: [{ usage: 1 }], + } as any, + }, + ], + signalTtlSeconds: 60, + }); + + const claimed = await claimSyncDirty({ redis, scope }); + expect(claimed!.modifiedCusEntIdsByFeatureId).toEqual({ + "messages:email": ["ce:1"], + }); + expect(claimed!.usageWindowUpdates[0].feature_id).toBe("messages:email"); + await clearSyncClaim({ redis, scope, generation: claimed!.generation }); +}); + +test(`${chalk.yellowBright("dirty-state: claim on fully empty state returns null")}`, async () => { + const redis = await wipe(); + const claimed = await claimSyncDirty({ redis, scope }); + expect(claimed).toBeNull(); +}); + +test(`${chalk.yellowBright("dirty-state: transient redis errors on signal job are retryable")}`, () => { + const redisError = Object.assign(new Error("Connection is closed."), { + name: "ConnectionError", + }); + expect( + shouldRetrySqsJobError({ + jobName: JobName.SyncCustomerDirty, + error: redisError, + }), + ).toBe(true); +}); diff --git a/server/tests/integration/balances/track/basic/track-deductions.test.ts b/server/tests/integration/balances/track/basic/track-deductions.test.ts index 5a84f2a235..dc8f8de050 100644 --- a/server/tests/integration/balances/track/basic/track-deductions.test.ts +++ b/server/tests/integration/balances/track/basic/track-deductions.test.ts @@ -10,7 +10,7 @@ import { import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; -import { timeout } from "@tests/utils/genUtils.js"; +import { pollUntil } from "@tests/utils/genUtils.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; import { Decimal } from "decimal.js"; @@ -372,14 +372,19 @@ test.concurrent( value: 12, }); - // Wait for the EventBatchingManager flush (350ms window) plus Tinybird - // ingest propagation. Existing credit-system tests use ~2s for cache - // sync; ~3s gives Tinybird a bit more slack. - await timeout(3000); - - const eventsList = (await autumnV1.events.list({ - customer_id: customerId, - })) as ApiEventsListResponse; + const eventsList = await pollUntil({ + fetch: () => + autumnV1.events.list({ + customer_id: customerId, + }) as Promise, + until: ({ list }) => + list.some( + (event) => + event.feature_id === TestFeature.Messages && event.value === 12, + ), + timeoutMs: 20_000, + intervalMs: 500, + }); expect(eventsList.list.length).toBeGreaterThan(0); const trackedEvent = eventsList.list.find( diff --git a/server/tests/integration/balances/track/basic/track-paid-features.test.ts b/server/tests/integration/balances/track/basic/track-paid-features.test.ts index 6be3cdecbe..5c8e5725df 100644 --- a/server/tests/integration/balances/track/basic/track-paid-features.test.ts +++ b/server/tests/integration/balances/track/basic/track-paid-features.test.ts @@ -10,7 +10,7 @@ import { TestFeature } from "@tests/setup/v2Features.js"; import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; -import { timeout } from "@tests/utils/genUtils.js"; +import { pollUntil } from "@tests/utils/genUtils.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; @@ -18,160 +18,176 @@ import chalk from "chalk"; // TRACK-PAID1: Prepaid tracking (no overage allowed) // ═══════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("track-paid1: prepaid tracking with no overage allowed")}`, async () => { - const prepaidItem = items.prepaidMessages({ - includedUsage: 2, - billingUnits: 1, - price: 1, - }); - - const prepaidProduct = products.pro({ - id: "prepaid", - items: [prepaidItem], - }); - - const prepaidQuantity = 3; - - const { customerId, autumnV1, autumnV2 } = await initScenario({ - customerId: "track-paid1", - setup: [ - s.customer({ testClock: false, paymentMethod: "success" }), - s.products({ list: [prepaidProduct] }), - ], - actions: [ - s.attach({ - productId: prepaidProduct.id, - options: [ - { - feature_id: TestFeature.Messages, - quantity: prepaidQuantity, - }, - ], - }), - ], - }); - - const customerBefore = - await autumnV1.customers.get(customerId); - expect(customerBefore.features[TestFeature.Messages].balance).toEqual(5); - - await expectAutumnError({ - errCode: ErrCode.InsufficientBalance, - func: async () => { - await autumnV1.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 7, - overage_behavior: "reject", - }); - }, - }); - - const customerAfterReject = - await autumnV1.customers.get(customerId); - expect(customerAfterReject.features[TestFeature.Messages].balance).toEqual(5); - - const trackRes: TrackResponseV2 = await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 4, - }); - - expect(trackRes.balance).toMatchObject({ - granted_balance: 2, - purchased_balance: 3, - current_balance: 1, - usage: 4, - }); - - await timeout(2000); - - const customerNonCached = (await autumnV2.customers.get(customerId, { - skip_cache: "true", - })) as unknown as ApiCustomer; - const feature = customerNonCached.balances[TestFeature.Messages]; - - expect(feature).toMatchObject({ - granted_balance: 2, - purchased_balance: 3, - current_balance: 1, - usage: 4, - }); -}); +test.concurrent( + `${chalk.yellowBright("track-paid1: prepaid tracking with no overage allowed")}`, + async () => { + const prepaidItem = items.prepaidMessages({ + includedUsage: 2, + billingUnits: 1, + price: 1, + }); + + const prepaidProduct = products.pro({ + id: "prepaid", + items: [prepaidItem], + }); + + const prepaidQuantity = 3; + + const { customerId, autumnV1, autumnV2 } = await initScenario({ + customerId: "track-paid1", + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [prepaidProduct] }), + ], + actions: [ + s.attach({ + productId: prepaidProduct.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: prepaidQuantity, + }, + ], + }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + expect(customerBefore.features[TestFeature.Messages].balance).toEqual(5); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => { + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 7, + overage_behavior: "reject", + }); + }, + }); + + const customerAfterReject = + await autumnV1.customers.get(customerId); + expect(customerAfterReject.features[TestFeature.Messages].balance).toEqual( + 5, + ); + + const trackRes: TrackResponseV2 = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 4, + }); + + expect(trackRes.balance).toMatchObject({ + granted_balance: 2, + purchased_balance: 3, + current_balance: 1, + usage: 4, + }); + + // Track deductions reach Postgres asynchronously, so poll the uncached read + // rather than betting on a fixed sleep. + const customerNonCached = await pollUntil({ + fetch: async () => + (await autumnV2.customers.get(customerId, { + skip_cache: "true", + })) as unknown as ApiCustomer, + until: (customer) => customer.balances[TestFeature.Messages]?.usage === 4, + }); + const feature = customerNonCached.balances[TestFeature.Messages]; + + expect(feature).toMatchObject({ + granted_balance: 2, + purchased_balance: 3, + current_balance: 1, + usage: 4, + }); + }, +); // ═══════════════════════════════════════════════════════════════════ // TRACK-PAID2: Pay-per-use/arrear tracking (overage allowed with usage limit) // ═══════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("track-paid2: pay-per-use tracking with overage allowed and usage limit")}`, async () => { - const payPerUseItem = items.consumableMessages({ - includedUsage: 5, - maxPurchase: 5, - price: 0.01, - }); - - const payPerUseProduct = products.pro({ - id: "payperuse", - items: [payPerUseItem], - }); - - const { customerId, autumnV1, autumnV2 } = await initScenario({ - customerId: "track-paid2", - setup: [ - s.customer({ testClock: false, paymentMethod: "success" }), - s.products({ list: [payPerUseProduct] }), - ], - actions: [s.attach({ productId: payPerUseProduct.id })], - }); - - const customerBefore = - await autumnV1.customers.get(customerId); - expect(customerBefore.features[TestFeature.Messages].balance).toEqual(5); - - const trackRes1: TrackResponseV2 = await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 7, - overage_behavior: "reject", - }); - - expect(trackRes1.balance).toMatchObject({ - granted_balance: 5, - purchased_balance: 2, - current_balance: 0, - usage: 7, - }); - - const customerAfter7 = - await autumnV1.customers.get(customerId); - expect(customerAfter7.features[TestFeature.Messages].balance).toEqual(-2); - expect(customerAfter7.features[TestFeature.Messages].usage).toEqual(7); - - const trackRes2: TrackResponseV2 = await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 3, - overage_behavior: "reject", - }); - - expect(trackRes2.balance).toMatchObject({ - granted_balance: 5, - purchased_balance: 5, - current_balance: 0, - usage: 10, - }); - - const customerAfter10 = - await autumnV1.customers.get(customerId); - expect(customerAfter10.features[TestFeature.Messages].balance).toEqual(-5); - expect(customerAfter10.features[TestFeature.Messages].usage).toEqual(10); - - await timeout(5000); - - const customerNonCached = await autumnV1.customers.get( - customerId, - { skip_cache: "true" }, - ); - expect(customerNonCached.features[TestFeature.Messages].balance).toEqual(-5); - expect(customerNonCached.features[TestFeature.Messages].usage).toEqual(10); -}); +test.concurrent( + `${chalk.yellowBright("track-paid2: pay-per-use tracking with overage allowed and usage limit")}`, + async () => { + const payPerUseItem = items.consumableMessages({ + includedUsage: 5, + maxPurchase: 5, + price: 0.01, + }); + + const payPerUseProduct = products.pro({ + id: "payperuse", + items: [payPerUseItem], + }); + + const { customerId, autumnV1, autumnV2 } = await initScenario({ + customerId: "track-paid2", + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [payPerUseProduct] }), + ], + actions: [s.attach({ productId: payPerUseProduct.id })], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + expect(customerBefore.features[TestFeature.Messages].balance).toEqual(5); + + const trackRes1: TrackResponseV2 = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 7, + overage_behavior: "reject", + }); + + expect(trackRes1.balance).toMatchObject({ + granted_balance: 5, + purchased_balance: 2, + current_balance: 0, + usage: 7, + }); + + const customerAfter7 = + await autumnV1.customers.get(customerId); + expect(customerAfter7.features[TestFeature.Messages].balance).toEqual(-2); + expect(customerAfter7.features[TestFeature.Messages].usage).toEqual(7); + + const trackRes2: TrackResponseV2 = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 3, + overage_behavior: "reject", + }); + + expect(trackRes2.balance).toMatchObject({ + granted_balance: 5, + purchased_balance: 5, + current_balance: 0, + usage: 10, + }); + + const customerAfter10 = + await autumnV1.customers.get(customerId); + expect(customerAfter10.features[TestFeature.Messages].balance).toEqual(-5); + expect(customerAfter10.features[TestFeature.Messages].usage).toEqual(10); + + const customerNonCached = await pollUntil({ + fetch: () => + autumnV1.customers.get(customerId, { + skip_cache: "true", + }), + until: (customer) => + customer.features[TestFeature.Messages].balance === -5, + }); + expect(customerNonCached.features[TestFeature.Messages].balance).toEqual( + -5, + ); + expect(customerNonCached.features[TestFeature.Messages].usage).toEqual(10); + }, +); diff --git a/server/tests/integration/balances/track/basic/track-tokens.test.ts b/server/tests/integration/balances/track/basic/track-tokens.test.ts index 5c809bb3de..26990ce403 100644 --- a/server/tests/integration/balances/track/basic/track-tokens.test.ts +++ b/server/tests/integration/balances/track/basic/track-tokens.test.ts @@ -9,6 +9,7 @@ import type { TrackResponseV3, } from "@autumn/shared"; import { ApiVersion, ErrCode, events, FeatureType } from "@autumn/shared"; +import { eventsDb } from "@tests/integration/balances/utils/events/getCustomerEvents.js"; import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; @@ -19,7 +20,6 @@ import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; import { Decimal } from "decimal.js"; import { and, desc, eq } from "drizzle-orm"; -import { db } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { getModelCreditCost } from "@/internal/features/aiCreditSystemUtils.js"; import { getModelsDevPricing } from "@/internal/features/utils/getModelPricing.js"; @@ -205,7 +205,7 @@ test.concurrent( const customer = (await autumnV2_2.customers.get(customerId, { with_autumn_id: true, })) as ApiCustomerV5 & { autumn_id?: string }; - const eventRows = await db + const eventRows = await eventsDb() .select({ created_at: events.created_at, timestamp: events.timestamp, diff --git a/server/tests/integration/balances/track/pooled-balances/track-pooled-balances.test.ts b/server/tests/integration/balances/track/pooled-balances/track-pooled-balances.test.ts new file mode 100644 index 0000000000..8092a9550c --- /dev/null +++ b/server/tests/integration/balances/track/pooled-balances/track-pooled-balances.test.ts @@ -0,0 +1,317 @@ +// Contract: Redis/Postgres track deduct the synthetic pool once and share updates across subjects. +// Exact exhaustion rejects overflow, while multiple pools deduct in shortest-reset-first order. + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + type ApiEntityV2, + EntInterval, + ErrCode, + PooledBalanceResetMode, + ResetInterval, + type TrackResponseV3, +} from "@autumn/shared"; +import { expectPooledBalanceCorrect } from "@tests/integration/billing/pooled-balances/utils/expectPooledBalanceCorrect.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +const CONTRIBUTION = 500; +const POOLED_GRANT = CONTRIBUTION * 2; + +test.concurrent( + `${chalk.yellowBright("pooled track: contributing and unassigned entities share deductions through exact exhaustion")}`, + async () => { + const pooledPlan = products.base({ + id: "pooled-track-shared", + items: [ + { + ...items.monthlyMessages({ includedUsage: CONTRIBUTION }), + pooled: true, + }, + ], + }); + const { autumnV2_2, ctx, customerId, entities } = await initScenario({ + customerId: "pooled-track-shared", + setup: [ + s.customer({ testClock: false }), + s.entities({ count: 3, featureId: TestFeature.Users }), + s.products({ list: [pooledPlan] }), + ], + actions: [ + s.billing.attach({ productId: pooledPlan.id, entityIndex: 0 }), + s.billing.attach({ productId: pooledPlan.id, entityIndex: 1 }), + ], + }); + await Promise.all([ + autumnV2_2.customers.get(customerId), + autumnV2_2.entities.get(customerId, entities[0].id), + autumnV2_2.entities.get(customerId, entities[1].id), + autumnV2_2.entities.get(customerId, entities[2].id), + ]); + + const firstTrack = (await autumnV2_2.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 300, + overage_behavior: "reject", + })) as TrackResponseV3; + expect(firstTrack).toMatchObject({ + customer_id: customerId, + entity_id: entities[0].id, + value: 300, + balance: { + granted: POOLED_GRANT, + remaining: 700, + usage: 300, + }, + }); + expect(firstTrack.deductions).toHaveLength(1); + expect(firstTrack.deductions?.[0]).toMatchObject({ + feature_id: TestFeature.Messages, + plan_id: null, + value: 300, + }); + + const customerAfterFirstTrack = + await autumnV2_2.customers.get(customerId); + const unassignedEntityAfterFirstTrack = + await autumnV2_2.entities.get(customerId, entities[2].id); + for (const subject of [ + customerAfterFirstTrack, + unassignedEntityAfterFirstTrack, + ]) { + expectBalanceCorrect({ + customer: subject, + featureId: TestFeature.Messages, + granted: POOLED_GRANT, + includedGrant: POOLED_GRANT, + remaining: 700, + usage: 300, + planId: null, + breakdownCount: 1, + }); + } + + const exactExhaustion = (await autumnV2_2.track({ + customer_id: customerId, + entity_id: entities[2].id, + feature_id: TestFeature.Messages, + value: 700, + overage_behavior: "reject", + })) as TrackResponseV3; + expect(exactExhaustion.balance).toMatchObject({ + granted: POOLED_GRANT, + remaining: 0, + usage: POOLED_GRANT, + }); + expect(exactExhaustion.deductions).toHaveLength(1); + expect(exactExhaustion.deductions?.[0]?.value).toBe(700); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2_2.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }), + }); + + await timeout(2000); + + const uncachedCustomer = await autumnV2_2.customers.get( + customerId, + { skip_cache: "true" }, + ); + const uncachedContributingEntity = + await autumnV2_2.entities.get(customerId, entities[1].id, { + skip_cache: "true", + }); + for (const subject of [uncachedCustomer, uncachedContributingEntity]) { + expectBalanceCorrect({ + customer: subject, + featureId: TestFeature.Messages, + granted: POOLED_GRANT, + includedGrant: POOLED_GRANT, + remaining: 0, + usage: POOLED_GRANT, + planId: null, + breakdownCount: 1, + }); + } + + await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: 0, + adjustment: 0, + granted: POOLED_GRANT, + interval: EntInterval.Month, + nextResetAt: "present", + resetCycleAnchor: "present", + resetMode: PooledBalanceResetMode.Lazy, + stripeSubscriptionId: null, + }, + contributions: { + count: 2, + currentContribution: CONTRIBUTION, + nextCycleContribution: CONTRIBUTION, + }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("pooled track: Postgres deductions return and persist the updated shared pool")}`, + async () => { + const pooledPlan = products.base({ + id: "pooled-track-postgres", + items: [ + { + ...items.monthlyMessages({ includedUsage: 250 }), + pooled: true, + }, + ], + }); + const { autumnV2_2, customerId, entities } = await initScenario({ + customerId: "pooled-track-postgres", + setup: [ + s.customer({ testClock: false }), + s.entities({ count: 3, featureId: TestFeature.Users }), + s.products({ list: [pooledPlan] }), + ], + actions: [ + s.billing.attach({ productId: pooledPlan.id, entityIndex: 0 }), + s.billing.attach({ productId: pooledPlan.id, entityIndex: 1 }), + ], + }); + + const trackResponse = (await autumnV2_2.track( + { + customer_id: customerId, + entity_id: entities[2].id, + feature_id: TestFeature.Messages, + value: 125, + overage_behavior: "reject", + }, + { skipCache: true }, + )) as TrackResponseV3; + + expect(trackResponse.balance).toMatchObject({ + granted: 500, + remaining: 375, + usage: 125, + }); + expect(trackResponse.deductions).toHaveLength(1); + expect(trackResponse.deductions?.[0]).toMatchObject({ + feature_id: TestFeature.Messages, + plan_id: null, + value: 125, + }); + + const customer = await autumnV2_2.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + granted: 500, + includedGrant: 500, + remaining: 375, + usage: 125, + planId: null, + breakdownCount: 1, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("pooled track: multiple pools deduct in shortest-reset-first order")}`, + async () => { + const hourlyPlan = products.base({ + id: "pooled-track-hourly", + items: [ + { + ...items.hourlyMessages({ includedUsage: 100 }), + pooled: true, + }, + ], + }); + const monthlyPlan = products.base({ + id: "pooled-track-monthly", + items: [ + { + ...items.monthlyMessages({ includedUsage: 200 }), + pooled: true, + }, + ], + }); + const { autumnV2_2, customerId, entities } = await initScenario({ + customerId: "pooled-track-global-order", + setup: [ + s.customer({ testClock: false }), + s.entities({ count: 3, featureId: TestFeature.Users }), + s.products({ list: [hourlyPlan, monthlyPlan] }), + ], + actions: [ + s.billing.attach({ productId: hourlyPlan.id, entityIndex: 0 }), + s.billing.attach({ productId: monthlyPlan.id, entityIndex: 1 }), + ], + }); + + const trackResponse = (await autumnV2_2.track({ + customer_id: customerId, + entity_id: entities[2].id, + feature_id: TestFeature.Messages, + value: 120, + overage_behavior: "reject", + })) as TrackResponseV3; + + expect(trackResponse.balance).toMatchObject({ + granted: 300, + remaining: 180, + usage: 120, + }); + expect(trackResponse.balance?.breakdown).toHaveLength(2); + const hourlyBreakdown = trackResponse.balance?.breakdown?.find( + (balance) => balance.reset?.interval === ResetInterval.Hour, + ); + const monthlyBreakdown = trackResponse.balance?.breakdown?.find( + (balance) => balance.reset?.interval === ResetInterval.Month, + ); + expect(hourlyBreakdown).toMatchObject({ + plan_id: null, + included_grant: 100, + remaining: 0, + usage: 100, + }); + expect(monthlyBreakdown).toMatchObject({ + plan_id: null, + included_grant: 200, + remaining: 180, + usage: 20, + }); + + expect( + trackResponse.deductions?.map((deduction) => ({ + interval: deduction.reset?.interval, + planId: deduction.plan_id, + value: deduction.value, + })), + ).toEqual([ + { interval: ResetInterval.Hour, planId: null, value: 100 }, + { interval: ResetInterval.Month, planId: null, value: 20 }, + ]); + }, +); diff --git a/server/tests/integration/balances/track/pooled-balances/track-pooled-mixed-lifecycle.test.ts b/server/tests/integration/balances/track/pooled-balances/track-pooled-mixed-lifecycle.test.ts new file mode 100644 index 0000000000..e4f6466aaf --- /dev/null +++ b/server/tests/integration/balances/track/pooled-balances/track-pooled-mixed-lifecycle.test.ts @@ -0,0 +1,161 @@ +// Contract: pooled entity grants compose with customer prepaid and overage balances. +// Mid-cycle contribution changes and free-to-Pro replacement preserve aggregate usage. + +import { expect, test } from "bun:test"; +import type { ApiCustomerV5, TrackResponseV3 } from "@autumn/shared"; +import { + buildPooledBalanceTestProducts, + pooledBalanceTestValues, +} from "@tests/integration/balances/utils/pooledBalanceTestProducts.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +test.concurrent( + `${chalk.yellowBright("pooled mixed lifecycle: customer prepaid and overage survive mid-cycle entity contributions")}`, + async () => { + const { customerAddon, proEntityPlan } = buildPooledBalanceTestProducts({ + idPrefix: "pooled-mixed-midcycle", + }); + const { autumnV2_2, ctx, customerId, entities, testClockId, advancedTo } = + await initScenario({ + customerId: "pooled-mixed-midcycle", + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.entities({ count: 2, featureId: TestFeature.Users }), + s.products({ list: [customerAddon, proEntityPlan] }), + ], + actions: [ + s.billing.attach({ + productId: customerAddon.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: pooledBalanceTestValues.prepaidQuantity, + }, + ], + }), + s.billing.attach({ productId: proEntityPlan.id, entityIndex: 0 }), + ], + }); + if (!testClockId) throw new Error("Expected a Stripe test clock"); + + const firstTrack = (await autumnV2_2.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 650, + overage_behavior: "reject", + })) as TrackResponseV3; + expect(firstTrack.balance).toMatchObject({ + granted: 700, + remaining: 50, + usage: 650, + overage_allowed: true, + }); + + const firstAdvance = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId, + startingFrom: new Date(advancedTo), + numberOfDays: 10, + waitForSeconds: 5, + }); + await autumnV2_2.billing.attach({ + customer_id: customerId, + entity_id: entities[1].id, + plan_id: proEntityPlan.id, + }); + + const afterSecondAttach = + await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer: afterSecondAttach, + featureId: TestFeature.Messages, + granted: 800, + remaining: 150, + usage: 650, + breakdownCount: 3, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId, + startingFrom: new Date(firstAdvance), + numberOfDays: 10, + waitForSeconds: 5, + }); + const afterSecondAdvance = + await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer: afterSecondAdvance, + featureId: TestFeature.Messages, + granted: 800, + remaining: 150, + usage: 650, + breakdownCount: 3, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("pooled mixed lifecycle: free-to-Pro replacement swaps one contribution without losing usage")}`, + async () => { + const { freeEntityPlan, proEntityPlan } = buildPooledBalanceTestProducts({ + idPrefix: "pooled-free-to-pro", + }); + const { autumnV2_2, customerId, entities } = await initScenario({ + customerId: "pooled-free-to-pro", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.entities({ count: 2, featureId: TestFeature.Users }), + s.products({ list: [freeEntityPlan, proEntityPlan] }), + ], + actions: [ + s.billing.attach({ productId: freeEntityPlan.id, entityIndex: 0 }), + s.billing.attach({ productId: freeEntityPlan.id, entityIndex: 1 }), + ], + }); + + await autumnV2_2.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 40, + }); + await timeout(2_000); + await autumnV2_2.billing.attach({ + customer_id: customerId, + entity_id: entities[0].id, + plan_id: proEntityPlan.id, + plan_schedule: "immediate", + }); + + const afterUpgrade = + await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer: afterUpgrade, + featureId: TestFeature.Messages, + granted: 150, + remaining: 110, + usage: 40, + breakdownCount: 2, + }); + + const secondTrack = (await autumnV2_2.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 70, + overage_behavior: "reject", + })) as TrackResponseV3; + expect(secondTrack.balance).toMatchObject({ + granted: 150, + remaining: 40, + usage: 110, + }); + }, +); diff --git a/server/tests/integration/balances/track/pooled-balances/track-pooled-rollovers.test.ts b/server/tests/integration/balances/track/pooled-balances/track-pooled-rollovers.test.ts new file mode 100644 index 0000000000..9739c67582 --- /dev/null +++ b/server/tests/integration/balances/track/pooled-balances/track-pooled-rollovers.test.ts @@ -0,0 +1,148 @@ +// Contract: a 50%-capped pooled rollover uses the aggregate grant in get/check/track. +// Rollover credits drain before the newly reset main pooled balance. + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + type CheckResponseV3, + EntInterval, + PooledBalanceResetMode, + RolloverExpiryDurationType, + type TrackResponseV3, +} from "@autumn/shared"; +import { expectPooledBalanceCorrect } from "@tests/integration/billing/pooled-balances/utils/expectPooledBalanceCorrect.js"; +import { expirePooledBalanceForReset } from "@tests/integration/billing/pooled-balances/utils/expirePooledBalanceForReset.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +const CONTRIBUTION = 200; +const POOLED_GRANT = CONTRIBUTION * 2; +const ROLLOVER_GRANT = POOLED_GRANT * 0.5; + +test.concurrent( + `${chalk.yellowBright("pooled rollover: max 50% is exposed and consumed before the reset grant")}`, + async () => { + const pooledPlan = products.base({ + id: "pooled-track-rollover-percent", + items: [ + { + ...items.monthlyMessagesWithRollover({ + includedUsage: CONTRIBUTION, + rolloverConfig: { + max_percentage: 50, + length: 1, + duration: RolloverExpiryDurationType.Month, + }, + }), + pooled: true, + }, + ], + }); + const { autumnV2_2, ctx, customerId, entities } = await initScenario({ + customerId: "pooled-track-rollover-percent", + setup: [ + s.customer({ testClock: false }), + s.entities({ count: 3, featureId: TestFeature.Users }), + s.products({ list: [pooledPlan] }), + ], + actions: [ + s.billing.attach({ productId: pooledPlan.id, entityIndex: 0 }), + s.billing.attach({ productId: pooledPlan.id, entityIndex: 1 }), + s.track({ + featureId: TestFeature.Messages, + value: 100, + entityIndex: 2, + timeout: 2_000, + }), + ], + }); + + await expirePooledBalanceForReset({ + ctx, + customerId, + resetMode: PooledBalanceResetMode.Lazy, + }); + const afterReset = await autumnV2_2.customers.get( + customerId, + { skip_cache: "true" }, + ); + expectBalanceCorrect({ + customer: afterReset, + featureId: TestFeature.Messages, + granted: POOLED_GRANT + ROLLOVER_GRANT, + remaining: POOLED_GRANT + ROLLOVER_GRANT, + usage: 0, + rollovers: [{ balance: ROLLOVER_GRANT }], + }); + + const [exactCheck, aboveCheck] = await Promise.all([ + autumnV2_2.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + required_balance: POOLED_GRANT + ROLLOVER_GRANT, + }), + autumnV2_2.check({ + customer_id: customerId, + entity_id: entities[2].id, + feature_id: TestFeature.Messages, + required_balance: POOLED_GRANT + ROLLOVER_GRANT + 1, + }), + ]); + expect(exactCheck.allowed).toBe(true); + expect(aboveCheck.allowed).toBe(false); + + const trackResponse = (await autumnV2_2.track({ + customer_id: customerId, + entity_id: entities[2].id, + feature_id: TestFeature.Messages, + value: 250, + overage_behavior: "reject", + })) as TrackResponseV3; + expect(trackResponse.balance).toMatchObject({ + granted: POOLED_GRANT + ROLLOVER_GRANT, + remaining: 350, + usage: 250, + }); + + await timeout(2_000); + const finalCustomer = await autumnV2_2.customers.get( + customerId, + { skip_cache: "true" }, + ); + expectBalanceCorrect({ + customer: finalCustomer, + featureId: TestFeature.Messages, + granted: POOLED_GRANT + ROLLOVER_GRANT, + remaining: 350, + usage: 250, + rollovers: [{ balance: 0 }], + }); + await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: 350, + adjustment: 0, + granted: POOLED_GRANT, + interval: EntInterval.Month, + nextResetAt: "present", + resetCycleAnchor: "present", + resetMode: PooledBalanceResetMode.Lazy, + stripeSubscriptionId: null, + rollovers: [{ balance: 0, usage: ROLLOVER_GRANT }], + }, + contributions: { + count: 2, + currentContribution: CONTRIBUTION, + nextCycleContribution: CONTRIBUTION, + }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + }, +); diff --git a/server/tests/integration/balances/track/spend-limit/pooled-balances/track-pooled-spend-limit.test.ts b/server/tests/integration/balances/track/spend-limit/pooled-balances/track-pooled-spend-limit.test.ts new file mode 100644 index 0000000000..494687dcf4 --- /dev/null +++ b/server/tests/integration/balances/track/spend-limit/pooled-balances/track-pooled-spend-limit.test.ts @@ -0,0 +1,103 @@ +// Contract: pooled grants and customer prepaid credits are free before priced overage. +// A 100-credit absolute cap permits exactly 100 units of $0.01 overage. + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + ErrCode, + type TrackResponseV3, +} from "@autumn/shared"; +import { + buildPooledBalanceTestProducts, + pooledBalanceTestValues, +} from "@tests/integration/balances/utils/pooledBalanceTestProducts.js"; +import { setCustomerSpendLimit } from "@tests/integration/balances/utils/spend-limit-utils/customerSpendLimitUtils.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +test.concurrent( + `${chalk.yellowBright("pooled spend limit: prepaid and pooled grants precede capped $0.01 overage")}`, + async () => { + const { customerAddon, proEntityPlan } = buildPooledBalanceTestProducts({ + idPrefix: "pooled-spend-limit", + }); + const { autumnV2_1, autumnV2_2, customerId, entities } = await initScenario( + { + customerId: "pooled-spend-limit", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.entities({ count: 2, featureId: TestFeature.Users }), + s.products({ list: [customerAddon, proEntityPlan] }), + ], + actions: [ + s.billing.attach({ + productId: customerAddon.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: pooledBalanceTestValues.prepaidQuantity, + }, + ], + }), + s.billing.attach({ productId: proEntityPlan.id, entityIndex: 0 }), + s.billing.attach({ productId: proEntityPlan.id, entityIndex: 1 }), + ], + }, + ); + + await setCustomerSpendLimit({ + autumn: autumnV2_1, + customerId, + featureId: TestFeature.Messages, + overageLimit: 100, + }); + await Promise.all([ + autumnV2_2.customers.get(customerId), + ...entities.map((entity) => + autumnV2_2.entities.get(customerId, entity.id), + ), + ]); + + const trackResponse = (await autumnV2_2.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 900, + })) as TrackResponseV3; + expect(trackResponse.balance).toMatchObject({ + granted: 800, + remaining: 0, + usage: 900, + overage_allowed: true, + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: () => + autumnV2_2.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }), + }); + + await timeout(2_000); + const customer = await autumnV2_2.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + granted: 800, + remaining: 0, + usage: 900, + breakdownCount: 3, + }); + }, +); diff --git a/server/tests/integration/balances/track/track-v3-idempotency.test.ts b/server/tests/integration/balances/track/track-v3-idempotency.test.ts index c36f67f1de..a0325b2909 100644 --- a/server/tests/integration/balances/track/track-v3-idempotency.test.ts +++ b/server/tests/integration/balances/track/track-v3-idempotency.test.ts @@ -1,11 +1,14 @@ import { expect, test } from "bun:test"; -import { ApiVersion, ApiVersionClass, ErrCode } from "@autumn/shared"; +import { ApiVersion, ApiVersionClass, ErrCode, ms } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import { Decimal } from "decimal.js"; import { getTrackFeatureDeductionsForBody } from "@/internal/balances/track/utils/getFeatureDeductions.js"; -import { getRedisTrackFeatureIdempotencyKey } from "@/internal/balances/track/v3/trackIdempotencyKey.js"; import { runTrackV3 } from "@/internal/balances/track/v3/runTrackV3.js"; +import { + getRedisTrackFeatureIdempotencyKey, + TRACK_V3_IDEMPOTENCY_TTL_MS, +} from "@/internal/balances/track/v3/trackIdempotencyKey.js"; import { buildCustomerMeteredScenario } from "../../db/full-subject/utils/fullSubjectScenarioBuilders.js"; import { withInsertedScenario } from "../../db/full-subject/utils/withInsertedScenario.js"; @@ -67,6 +70,9 @@ test("track-v3 idempotency is atomic for single-feature requests", async () => { featureId: TestFeature.Messages, }); expect(await ctx.redisV2.exists(redisKey)).toBe(1); + const ttlMs = await ctx.redisV2.pttl(redisKey); + expect(ttlMs).toBeGreaterThan(ms.hours(23)); + expect(ttlMs).toBeLessThanOrEqual(TRACK_V3_IDEMPOTENCY_TTL_MS); }, }); }); diff --git a/server/tests/integration/balances/track/usage-alerts/pooled-balances/pooled-balance-usage-alert.test.ts b/server/tests/integration/balances/track/usage-alerts/pooled-balances/pooled-balance-usage-alert.test.ts new file mode 100644 index 0000000000..cc13ce23db --- /dev/null +++ b/server/tests/integration/balances/track/usage-alerts/pooled-balances/pooled-balance-usage-alert.test.ts @@ -0,0 +1,120 @@ +// Contract: usage-percentage alerts use the aggregate pooled grant after replacement. +// Tracking on an entity fires the customer-scoped alert exactly at 50% usage. + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { + buildPooledBalanceTestProducts, + pooledBalanceTestValues, +} from "@tests/integration/balances/utils/pooledBalanceTestProducts.js"; +import { setCustomerUsageAlerts } from "@tests/integration/balances/utils/usage-alert-utils/customerUsageAlertUtils.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { + getTestSvixAppId, + setupWebhookTest, + type WebhookTestSetup, + waitForWebhook, +} from "@tests/integration/utils/svixWebhookTestUtils.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +type UsageAlertPayload = { + type: string; + data: { + customer_id: string; + feature_id: string; + entity_id?: string; + usage_alert: { threshold: number; threshold_type: string }; + }; +}; + +let webhook: WebhookTestSetup; + +beforeAll(async () => { + webhook = await setupWebhookTest({ + appId: getTestSvixAppId({ svixConfig: ctx.org.svix_config }), + filterTypes: ["balances.usage_alert_triggered"], + }); +}); + +afterAll(async () => { + await webhook?.cleanup(); +}); + +test(`${chalk.yellowBright("pooled usage alert: free-to-Pro aggregate fires at exactly 50% usage")}`, async () => { + const { freeEntityPlan, proEntityPlan } = buildPooledBalanceTestProducts({ + idPrefix: "pooled-usage-alert", + }); + const { autumnV2_1, autumnV2_2, customerId, entities } = await initScenario({ + customerId: "pooled-usage-alert", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.entities({ count: 2, featureId: TestFeature.Users }), + s.products({ list: [freeEntityPlan, proEntityPlan] }), + ], + actions: [ + s.billing.attach({ productId: freeEntityPlan.id, entityIndex: 0 }), + s.billing.attach({ productId: freeEntityPlan.id, entityIndex: 1 }), + s.track({ + featureId: TestFeature.Messages, + value: 40, + entityIndex: 1, + timeout: 2_000, + }), + ], + }); + await autumnV2_2.billing.attach({ + customer_id: customerId, + entity_id: entities[0].id, + plan_id: proEntityPlan.id, + plan_schedule: "immediate", + }); + await setCustomerUsageAlerts({ + autumn: autumnV2_1, + customerId, + usageAlerts: [ + { + feature_id: TestFeature.Messages, + threshold: 50, + threshold_type: "usage_percentage", + enabled: true, + }, + ], + }); + await autumnV2_2.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 35, + }); + const event = await waitForWebhook({ + token: webhook.playToken, + predicate: (payload) => + payload.type === "balances.usage_alert_triggered" && + payload.data?.customer_id === customerId && + payload.data?.feature_id === TestFeature.Messages && + payload.data?.entity_id === undefined && + payload.data?.usage_alert?.threshold === 50 && + payload.data?.usage_alert?.threshold_type === "usage_percentage", + timeoutMs: 15_000, + }); + expect(event).not.toBeNull(); + + await timeout(2_000); + const customer = await autumnV2_2.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + granted: + pooledBalanceTestValues.freeContribution + + pooledBalanceTestValues.proContribution, + remaining: 75, + usage: 75, + breakdownCount: 2, + }); +}); diff --git a/server/tests/integration/balances/usage-windows/pooled-balances/pooled-balance-usage-window.test.ts b/server/tests/integration/balances/usage-windows/pooled-balances/pooled-balance-usage-window.test.ts new file mode 100644 index 0000000000..de7d2e66a7 --- /dev/null +++ b/server/tests/integration/balances/usage-windows/pooled-balances/pooled-balance-usage-window.test.ts @@ -0,0 +1,100 @@ +// Contract: a customer usage window gates tracks across entities sharing pooled grants. +// Existing balance usage is preserved while only post-limit tracks consume window headroom. + +import { expect, test } from "bun:test"; +import { ErrCode, type TrackResponseV3 } from "@autumn/shared"; +import { + buildPooledBalanceTestProducts, + pooledBalanceTestValues, +} from "@tests/integration/balances/utils/pooledBalanceTestProducts.js"; +import { + expectCustomerUsageLimit, + setCustomerUsageLimit, +} from "@tests/integration/balances/utils/usage-limit-utils/customerUsageLimitUtils.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +test.concurrent( + `${chalk.yellowBright("pooled usage window: a shared cap clamps tracks after free-to-Pro replacement")}`, + async () => { + const { freeEntityPlan, proEntityPlan } = buildPooledBalanceTestProducts({ + idPrefix: "pooled-usage-window", + }); + const { autumnV2_2, autumnV2_3, customerId, entities } = await initScenario( + { + customerId: "pooled-usage-window", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.entities({ count: 2, featureId: TestFeature.Users }), + s.products({ list: [freeEntityPlan, proEntityPlan] }), + ], + actions: [ + s.billing.attach({ productId: freeEntityPlan.id, entityIndex: 0 }), + s.billing.attach({ productId: freeEntityPlan.id, entityIndex: 1 }), + s.track({ + featureId: TestFeature.Messages, + value: 40, + entityIndex: 1, + timeout: 2_000, + }), + ], + }, + ); + await autumnV2_2.billing.attach({ + customer_id: customerId, + entity_id: entities[0].id, + plan_id: proEntityPlan.id, + plan_schedule: "immediate", + }); + + const windowLimit = 35; + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: windowLimit, + }); + await Promise.all([ + autumnV2_3.customers.get(customerId), + ...entities.map((entity) => + autumnV2_3.entities.get(customerId, entity.id), + ), + ]); + + const clampedTrack = (await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 60, + })) as TrackResponseV3; + expect(clampedTrack.value).toBe(60); + expect(clampedTrack.balance).toMatchObject({ + granted: + pooledBalanceTestValues.freeContribution + + pooledBalanceTestValues.proContribution, + remaining: 75, + usage: 75, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: windowLimit, + limit: windowLimit, + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: () => + autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }), + }); + }, +); diff --git a/server/tests/integration/balances/utils/events/getCustomerEvents.ts b/server/tests/integration/balances/utils/events/getCustomerEvents.ts index 019abedab5..9f689edce1 100644 --- a/server/tests/integration/balances/utils/events/getCustomerEvents.ts +++ b/server/tests/integration/balances/utils/events/getCustomerEvents.ts @@ -1,8 +1,13 @@ import { ApiVersion } from "@autumn/shared"; +import { neonEventsDb } from "@server/db/initNeonEvents.js"; import { AutumnInt } from "@server/external/autumn/autumnCli.js"; import { EventService } from "@server/internal/api/events/EventService.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +/** Events land in the split Neon events DB when configured; mirror the + * writer's resolution so asserts read where the server wrote. */ +export const eventsDb = () => neonEventsDb ?? ctx.db; + export const getCustomerEvents = async ({ customerId, }: { @@ -15,7 +20,7 @@ export const getCustomerEvents = async ({ }); const events = await EventService.getByCustomerId({ - db: ctx.db, + db: eventsDb(), orgId: ctx.org.id, internalCustomerId: customer.autumn_id ?? "", env: ctx.env, diff --git a/server/tests/integration/balances/utils/pooledBalanceTestProducts.ts b/server/tests/integration/balances/utils/pooledBalanceTestProducts.ts new file mode 100644 index 0000000000..65d56984a2 --- /dev/null +++ b/server/tests/integration/balances/utils/pooledBalanceTestProducts.ts @@ -0,0 +1,61 @@ +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; + +export const pooledBalanceTestValues = { + freeContribution: 50, + prepaidQuantity: 600, + proContribution: 100, + overagePrice: 0.01, +} as const; + +export const buildPooledBalanceTestProducts = ({ + idPrefix, +}: { + idPrefix: string; +}) => { + const entityPlanGroup = `${idPrefix}-entity-plan`; + const customerAddon = products.base({ + id: `${idPrefix}-customer-addon`, + isAddOn: true, + items: [ + items.volumePrepaidMessages({ + includedUsage: 0, + billingUnits: 100, + tiers: [ + { to: 500, amount: 10 }, + { to: "inf", amount: 5 }, + ], + }), + items.consumableMessages({ + includedUsage: 0, + price: pooledBalanceTestValues.overagePrice, + }), + ], + }); + const freeEntityPlan = products.base({ + id: `${idPrefix}-free`, + group: entityPlanGroup, + items: [ + { + ...items.monthlyMessages({ + includedUsage: pooledBalanceTestValues.freeContribution, + }), + pooled: true, + }, + ], + }); + const proEntityPlan = products.pro({ + id: `${idPrefix}-pro`, + group: entityPlanGroup, + items: [ + { + ...items.monthlyMessages({ + includedUsage: pooledBalanceTestValues.proContribution, + }), + pooled: true, + }, + ], + }); + + return { customerAddon, freeEntityPlan, proEntityPlan }; +}; diff --git a/server/tests/integration/billing/attach/discounts/promotion-code-applies-to-validation.test.ts b/server/tests/integration/billing/attach/discounts/promotion-code-applies-to-validation.test.ts new file mode 100644 index 0000000000..e1d02e0137 --- /dev/null +++ b/server/tests/integration/billing/attach/discounts/promotion-code-applies-to-validation.test.ts @@ -0,0 +1,110 @@ +/** + * TDD test for promotion codes restricted to products outside the attach order. + * + * Red-failure mode (current behavior): + * - Preview accepts the promo and displays a discount that Stripe will not apply. + * + * Green-success criteria (after fix): + * - Preview rejects the promo before showing an inapplicable discount. + */ + +import { test } from "bun:test"; +import { type AttachParamsV1Input, ErrCode } from "@autumn/shared"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { + createPercentCoupon, + createPromotionCode, +} from "../../utils/discounts/discountTestUtils.js"; + +test.concurrent( + `${chalk.yellowBright("attach discount: rejects promo restricted to another product")}`, + async () => { + const customerId = "promo-applies-to-other-product"; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [s.customer({}), s.products({ list: [pro] })], + actions: [], + }); + + const otherProduct = await ctx.stripeCli.products.create({ + name: customerId, + }); + const coupon = await createPercentCoupon({ + stripeCli: ctx.stripeCli, + percentOff: 50, + appliesToProducts: [otherProduct.id], + }); + const promotionCode = await createPromotionCode({ + stripeCli: ctx.stripeCli, + coupon, + code: "OTHER_PRODUCT", + }); + const params: AttachParamsV1Input = { + customer_id: customerId, + plan_id: pro.id, + discounts: [{ promotion_code: promotionCode.code }], + }; + + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + errMessage: "does not apply to any products in this order", + func: () => autumnV2_2.billing.previewAttach(params), + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("attach discount: rejects unrelated promo for scheduled attach")}`, + async () => { + const customerId = "scheduled-promo-applies-to-other-product"; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const premium = products.premium({ + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const otherProduct = await ctx.stripeCli.products.create({ + name: customerId, + }); + const coupon = await createPercentCoupon({ + stripeCli: ctx.stripeCli, + percentOff: 50, + appliesToProducts: [otherProduct.id], + }); + const promotionCode = await createPromotionCode({ + stripeCli: ctx.stripeCli, + coupon, + code: "OTHER_SCHEDULED_PRODUCT", + }); + const params: AttachParamsV1Input = { + customer_id: customerId, + plan_id: premium.id, + plan_schedule: "end_of_cycle", + discounts: [{ promotion_code: promotionCode.code }], + }; + + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + errMessage: "does not apply to any products in this order", + func: () => autumnV2_2.billing.previewAttach(params), + }); + }, +); diff --git a/server/tests/integration/billing/attach/invoice/attach-invoice-draft-deferred.test.ts b/server/tests/integration/billing/attach/invoice/attach-invoice-draft-deferred.test.ts index 41f5555b2f..da6bc41c8b 100644 --- a/server/tests/integration/billing/attach/invoice/attach-invoice-draft-deferred.test.ts +++ b/server/tests/integration/billing/attach/invoice/attach-invoice-draft-deferred.test.ts @@ -24,11 +24,11 @@ import { expectProductActive, } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; import { expectProductTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { waitForCustomerInvoiceStatus } from "@tests/integration/billing/utils/waitForCustomerInvoiceStatus"; import { TestFeature } from "@tests/setup/v2Features"; import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2"; import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; -import { timeout } from "@tests/utils/genUtils"; import ctx from "@tests/utils/testInitUtils/createTestContext"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; @@ -47,108 +47,115 @@ import chalk from "chalk"; * - Product NOT activated (deferred) * - After finalizing and paying: product activates */ -test.concurrent(`${chalk.yellowBright("attach-invoice-draft-def 1: new plan")}`, async () => { - const customerId = "attach-inv-draft-def-new-plan"; - - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [messagesItem, priceItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [], - }); - - // After initScenario, pro.id is mutated to include the prefix - - // Preview attach - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - }); - expect(preview.total).toBe(20); - - // Attach with invoice mode (draft, deferred) - const result = await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - invoice: true, - finalize_invoice: false, - enable_product_immediately: false, - redirect_mode: "if_required", - }); - - // Verify invoice is draft - expect(result.invoice).toBeDefined(); - expect(result.invoice!.status).toBe("draft"); - expect(result.invoice!.stripe_id).toBeDefined(); - expect(result.invoice!.total).toBe(preview.total); - expect(result.invoice!.hosted_invoice_url).toBeNull(); // Draft invoices don't have hosted URL - - const stripeInvoice = await ctx.stripeCli.invoices.retrieve( - result.invoice!.stripe_id, - ); - expect(stripeInvoice.status).toBe("draft"); - - const customerBefore = - await autumnV1.customers.get(customerId); - - // Before payment - product should NOT be activated (deferred) - // Messages feature should not exist yet - expect(customerBefore.features?.[TestFeature.Messages]).toBeUndefined(); - - // Verify invoice status in customer - await expectCustomerInvoiceCorrect({ - customer: customerBefore, - count: 1, - latestTotal: preview.total, - latestStatus: "draft", - }); - - // Finalize the invoice - const finalizedInvoice = await ctx.stripeCli.invoices.finalizeInvoice( - result.invoice!.stripe_id, - ); - expect(finalizedInvoice.status).toBe("open"); - expect(finalizedInvoice.hosted_invoice_url).toBeDefined(); - - // Complete payment - await completeInvoiceCheckout({ - url: finalizedInvoice.hosted_invoice_url!, - }); - - const customerAfter = await autumnV1.customers.get(customerId); - - // After payment - product should be active - await expectProductActive({ - customer: customerAfter, - productId: pro.id, - }); - - // Verify balance is correct - expectCustomerFeatureCorrect({ - customer: customerAfter, - featureId: TestFeature.Messages, - includedUsage: 100, - balance: 100, - usage: 0, - }); - - // Verify invoice is now paid - await expectCustomerInvoiceCorrect({ - customer: customerAfter, - count: 1, - latestTotal: preview.total, - latestStatus: "paid", - }); -}); +test.concurrent( + `${chalk.yellowBright("attach-invoice-draft-def 1: new plan")}`, + async () => { + const customerId = "attach-inv-draft-def-new-plan"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + // After initScenario, pro.id is mutated to include the prefix + + // Preview attach + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: pro.id, + }); + expect(preview.total).toBe(20); + + // Attach with invoice mode (draft, deferred) + const result = await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + invoice: true, + finalize_invoice: false, + enable_product_immediately: false, + redirect_mode: "if_required", + }); + + // Verify invoice is draft + expect(result.invoice).toBeDefined(); + expect(result.invoice!.status).toBe("draft"); + expect(result.invoice!.stripe_id).toBeDefined(); + expect(result.invoice!.total).toBe(preview.total); + expect(result.invoice!.hosted_invoice_url).toBeNull(); // Draft invoices don't have hosted URL + + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + result.invoice!.stripe_id, + ); + expect(stripeInvoice.status).toBe("draft"); + + const customerBefore = + await autumnV1.customers.get(customerId); + + // Before payment - product should NOT be activated (deferred) + // Messages feature should not exist yet + expect(customerBefore.features?.[TestFeature.Messages]).toBeUndefined(); + + // Verify invoice status in customer + await expectCustomerInvoiceCorrect({ + customer: customerBefore, + count: 1, + latestTotal: preview.total, + latestStatus: "draft", + }); + + // Finalize the invoice + const finalizedInvoice = await ctx.stripeCli.invoices.finalizeInvoice( + result.invoice!.stripe_id, + ); + expect(finalizedInvoice.status).toBe("open"); + expect(finalizedInvoice.hosted_invoice_url).toBeDefined(); + + // Complete payment + await completeInvoiceCheckout({ + url: finalizedInvoice.hosted_invoice_url!, + }); + + const customerAfter = await waitForCustomerInvoiceStatus({ + autumn: autumnV1, + customerId, + status: "paid", + }); + + // After payment - product should be active + await expectProductActive({ + customer: customerAfter, + productId: pro.id, + }); + + // Verify balance is correct + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + // Verify invoice is now paid + await expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 1, + latestTotal: preview.total, + latestStatus: "paid", + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 2: Upgrade (draft, deferred) @@ -165,118 +172,125 @@ test.concurrent(`${chalk.yellowBright("attach-invoice-draft-def 1: new plan")}`, * - Pro trial stays active until invoice is paid * - After payment: premium active and invoice paid */ -test.concurrent(`${chalk.yellowBright("attach-invoice-draft-def 2: upgrade")}`, async () => { - const customerId = "attach-inv-draft-def-upgrade"; - - const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); - const proTrial = products.proWithTrial({ - id: "pro-trial", - items: [proMessagesItem], - trialDays: 7, - cardRequired: false, - }); - - const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const premium = products.premium({ - id: "premium", - items: [premiumMessagesItem], - }); - - const { autumnV1, advancedTo } = await initScenario({ - customerId, - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [proTrial, premium] }), - ], - actions: [ - s.billing.attach({ productId: proTrial.id }), - s.advanceTestClock({ days: 12 }), - ], - }); - - const customerDuringTrial = - await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer: customerDuringTrial, - active: [proTrial.id], - notPresent: [premium.id], - }); - - // Preview upgrade - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: premium.id, - }); - - // Attach premium with invoice mode (draft, deferred) - const result = await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - invoice: true, - finalize_invoice: true, - enable_product_immediately: false, - redirect_mode: "if_required", - }); - - // Verify invoice is draft - expect(result.invoice).toBeDefined(); - expect(result.invoice!.status).toBe("open"); - expect(result.invoice!.stripe_id).toBeDefined(); - expect(result.invoice!.total).toBeLessThanOrEqual(preview.total + 0.01); - expect(result.invoice!.total).toBeGreaterThanOrEqual(preview.total - 0.01); - - const customerBefore = - await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer: customerBefore, - active: [proTrial.id], - notPresent: [premium.id], - }); - - await expectCustomerInvoiceCorrect({ - customer: customerBefore, - count: 3, - latestTotal: preview.total, - latestStatus: "open", - }); - - // Complete payment - await completeInvoiceCheckout({ - url: result.invoice!.hosted_invoice_url!, - }); - - const paidInvoice = await ctx.stripeCli.invoices.retrieve( - result.invoice!.stripe_id, - ); - expect(paidInvoice.status).toBe("paid"); - - const customerAfter = await autumnV1.customers.get(customerId); - - // After payment - premium active, pro removed - await expectCustomerProducts({ - customer: customerAfter, - active: [premium.id], - notPresent: [proTrial.id], - }); - - // Verify balance is premium's balance - expectCustomerFeatureCorrect({ - customer: customerAfter, - featureId: TestFeature.Messages, - includedUsage: 1000, - balance: 1000, - usage: 0, - }); - - await expectCustomerInvoiceCorrect({ - customer: customerAfter, - count: 3, - latestTotal: preview.total, - latestStatus: "paid", - }); -}); +test.concurrent( + `${chalk.yellowBright("attach-invoice-draft-def 2: upgrade")}`, + async () => { + const customerId = "attach-inv-draft-def-upgrade"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 500 }); + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [proMessagesItem], + trialDays: 7, + cardRequired: false, + }); + + const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const premium = products.premium({ + id: "premium", + items: [premiumMessagesItem], + }); + + const { autumnV1, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [proTrial, premium] }), + ], + actions: [ + s.billing.attach({ productId: proTrial.id }), + s.advanceTestClock({ days: 12 }), + ], + }); + + const customerDuringTrial = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerDuringTrial, + active: [proTrial.id], + notPresent: [premium.id], + }); + + // Preview upgrade + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + }); + + // Attach premium with invoice mode (draft, deferred) + const result = await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + invoice: true, + finalize_invoice: true, + enable_product_immediately: false, + redirect_mode: "if_required", + }); + + // Verify invoice is draft + expect(result.invoice).toBeDefined(); + expect(result.invoice!.status).toBe("open"); + expect(result.invoice!.stripe_id).toBeDefined(); + expect(result.invoice!.total).toBeLessThanOrEqual(preview.total + 0.01); + expect(result.invoice!.total).toBeGreaterThanOrEqual(preview.total - 0.01); + + const customerBefore = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerBefore, + active: [proTrial.id], + notPresent: [premium.id], + }); + + await expectCustomerInvoiceCorrect({ + customer: customerBefore, + count: 3, + latestTotal: preview.total, + latestStatus: "open", + }); + + // Complete payment + await completeInvoiceCheckout({ + url: result.invoice!.hosted_invoice_url!, + }); + + const paidInvoice = await ctx.stripeCli.invoices.retrieve( + result.invoice!.stripe_id, + ); + expect(paidInvoice.status).toBe("paid"); + + const customerAfter = await waitForCustomerInvoiceStatus({ + autumn: autumnV1, + customerId, + status: "paid", + }); + + // After payment - premium active, pro removed + await expectCustomerProducts({ + customer: customerAfter, + active: [premium.id], + notPresent: [proTrial.id], + }); + + // Verify balance is premium's balance + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: 1000, + balance: 1000, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 3, + latestTotal: preview.total, + latestStatus: "paid", + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 3: New plan without payment method (draft, deferred) @@ -293,109 +307,116 @@ test.concurrent(`${chalk.yellowBright("attach-invoice-draft-def 2: upgrade")}`, * - Product NOT activated (deferred) * - After finalizing and paying: product activates */ -test.concurrent(`${chalk.yellowBright("attach-invoice-draft-def 3: new plan no pm")}`, async () => { - const customerId = "attach-inv-draft-def-new-plan-no-pm"; - - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [messagesItem, priceItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({}), // No payment method - s.products({ list: [pro] }), - ], - actions: [], - }); - - // Preview attach - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - }); - expect(preview.total).toBe(20); - - // Attach with invoice mode (draft, deferred) - const result = await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - invoice: true, - finalize_invoice: false, - enable_product_immediately: false, - redirect_mode: "if_required", - }); - - // Verify invoice is draft - expect(result.invoice).toBeDefined(); - expect(result.invoice!.status).toBe("draft"); - expect(result.invoice!.stripe_id).toBeDefined(); - expect(result.invoice!.total).toBe(preview.total); - expect(result.invoice!.hosted_invoice_url).toBeNull(); // Draft invoices don't have hosted URL - - // payment_url should NOT be defined for draft invoices - expect(result.payment_url).toBeNull(); - - const stripeInvoice = await ctx.stripeCli.invoices.retrieve( - result.invoice!.stripe_id, - ); - expect(stripeInvoice.status).toBe("draft"); - - const customerBefore = - await autumnV1.customers.get(customerId); - - // Before payment - product should NOT be activated (deferred) - // Messages feature should not exist yet - expect(customerBefore.features?.[TestFeature.Messages]).toBeUndefined(); - - // Verify invoice status in customer - await expectCustomerInvoiceCorrect({ - customer: customerBefore, - count: 1, - latestTotal: preview.total, - latestStatus: "draft", - }); - - // Finalize the invoice - const finalizedInvoice = await ctx.stripeCli.invoices.finalizeInvoice( - result.invoice!.stripe_id, - ); - expect(finalizedInvoice.status).toBe("open"); - expect(finalizedInvoice.hosted_invoice_url).toBeDefined(); - - // Complete payment - await completeInvoiceCheckout({ - url: finalizedInvoice.hosted_invoice_url!, - }); - - const customerAfter = await autumnV1.customers.get(customerId); - - // After payment - product should be active - await expectProductActive({ - customer: customerAfter, - productId: pro.id, - }); - - // Verify balance is correct - expectCustomerFeatureCorrect({ - customer: customerAfter, - featureId: TestFeature.Messages, - includedUsage: 100, - balance: 100, - usage: 0, - }); - - // Verify invoice is now paid - await expectCustomerInvoiceCorrect({ - customer: customerAfter, - count: 1, - latestTotal: preview.total, - latestStatus: "paid", - }); -}); +test.concurrent( + `${chalk.yellowBright("attach-invoice-draft-def 3: new plan no pm")}`, + async () => { + const customerId = "attach-inv-draft-def-new-plan-no-pm"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({}), // No payment method + s.products({ list: [pro] }), + ], + actions: [], + }); + + // Preview attach + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: pro.id, + }); + expect(preview.total).toBe(20); + + // Attach with invoice mode (draft, deferred) + const result = await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + invoice: true, + finalize_invoice: false, + enable_product_immediately: false, + redirect_mode: "if_required", + }); + + // Verify invoice is draft + expect(result.invoice).toBeDefined(); + expect(result.invoice!.status).toBe("draft"); + expect(result.invoice!.stripe_id).toBeDefined(); + expect(result.invoice!.total).toBe(preview.total); + expect(result.invoice!.hosted_invoice_url).toBeNull(); // Draft invoices don't have hosted URL + + // payment_url should NOT be defined for draft invoices + expect(result.payment_url).toBeNull(); + + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + result.invoice!.stripe_id, + ); + expect(stripeInvoice.status).toBe("draft"); + + const customerBefore = + await autumnV1.customers.get(customerId); + + // Before payment - product should NOT be activated (deferred) + // Messages feature should not exist yet + expect(customerBefore.features?.[TestFeature.Messages]).toBeUndefined(); + + // Verify invoice status in customer + await expectCustomerInvoiceCorrect({ + customer: customerBefore, + count: 1, + latestTotal: preview.total, + latestStatus: "draft", + }); + + // Finalize the invoice + const finalizedInvoice = await ctx.stripeCli.invoices.finalizeInvoice( + result.invoice!.stripe_id, + ); + expect(finalizedInvoice.status).toBe("open"); + expect(finalizedInvoice.hosted_invoice_url).toBeDefined(); + + // Complete payment + await completeInvoiceCheckout({ + url: finalizedInvoice.hosted_invoice_url!, + }); + + const customerAfter = await waitForCustomerInvoiceStatus({ + autumn: autumnV1, + customerId, + status: "paid", + }); + + // After payment - product should be active + await expectProductActive({ + customer: customerAfter, + productId: pro.id, + }); + + // Verify balance is correct + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + // Verify invoice is now paid + await expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 1, + latestTotal: preview.total, + latestStatus: "paid", + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 4: One-off (draft, deferred) @@ -411,95 +432,100 @@ test.concurrent(`${chalk.yellowBright("attach-invoice-draft-def 3: new plan no p * - Credits NOT granted until payment * - After payment: credits granted */ -test.concurrent(`${chalk.yellowBright("attach-invoice-draft-def 4: one-off")}`, async () => { - const customerId = "attach-inv-draft-def-oneoff"; - - const oneOffMessagesItem = items.oneOffMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }); - - const oneOff = products.oneOff({ - id: "one-off-credits", - items: [oneOffMessagesItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [oneOff] }), - ], - actions: [], - }); - - // After initScenario, oneOff.id is mutated to include the prefix - - // Preview attach - base ($10) + prepaid ($10) = $20 - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: oneOff.id, - options: [{ feature_id: TestFeature.Messages, quantity: 100 }], - }); - expect(preview.total).toBe(20); - - // Attach with invoice mode (draft, deferred) - const result = await autumnV1.billing.attach({ - customer_id: customerId, - product_id: oneOff.id, - options: [{ feature_id: TestFeature.Messages, quantity: 100 }], - invoice: true, - finalize_invoice: false, - enable_product_immediately: false, - redirect_mode: "if_required", - }); - - // Verify invoice is draft - expect(result.invoice).toBeDefined(); - expect(result.invoice!.status).toBe("draft"); - expect(result.invoice!.stripe_id).toBeDefined(); - expect(result.invoice!.total).toBe(preview.total); - - const customerBefore = - await autumnV1.customers.get(customerId); - - // Before payment - credits should NOT be granted - expect(customerBefore.features?.[TestFeature.Messages]).toBeUndefined(); - - // Finalize the invoice - const finalizedInvoice = await ctx.stripeCli.invoices.finalizeInvoice( - result.invoice!.stripe_id, - ); - expect(finalizedInvoice.status).toBe("open"); - - // Complete payment - await completeInvoiceCheckout({ - url: finalizedInvoice.hosted_invoice_url!, - }); - - await timeout(10000); - - const customerAfter = await autumnV1.customers.get(customerId); - - // After payment - product active and credits granted - await expectProductActive({ - customer: customerAfter, - productId: oneOff.id, - }); - - expectCustomerFeatureCorrect({ - customer: customerAfter, - featureId: TestFeature.Messages, - balance: 100, - usage: 0, - }); - - // Verify invoice is paid - await expectCustomerInvoiceCorrect({ - customer: customerAfter, - count: 1, - latestTotal: preview.total, - latestStatus: "paid", - }); -}); +test.concurrent( + `${chalk.yellowBright("attach-invoice-draft-def 4: one-off")}`, + async () => { + const customerId = "attach-inv-draft-def-oneoff"; + + const oneOffMessagesItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + + const oneOff = products.oneOff({ + id: "one-off-credits", + items: [oneOffMessagesItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOff] }), + ], + actions: [], + }); + + // After initScenario, oneOff.id is mutated to include the prefix + + // Preview attach - base ($10) + prepaid ($10) = $20 + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: oneOff.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }); + expect(preview.total).toBe(20); + + // Attach with invoice mode (draft, deferred) + const result = await autumnV1.billing.attach({ + customer_id: customerId, + product_id: oneOff.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + invoice: true, + finalize_invoice: false, + enable_product_immediately: false, + redirect_mode: "if_required", + }); + + // Verify invoice is draft + expect(result.invoice).toBeDefined(); + expect(result.invoice!.status).toBe("draft"); + expect(result.invoice!.stripe_id).toBeDefined(); + expect(result.invoice!.total).toBe(preview.total); + + const customerBefore = + await autumnV1.customers.get(customerId); + + // Before payment - credits should NOT be granted + expect(customerBefore.features?.[TestFeature.Messages]).toBeUndefined(); + + // Finalize the invoice + const finalizedInvoice = await ctx.stripeCli.invoices.finalizeInvoice( + result.invoice!.stripe_id, + ); + expect(finalizedInvoice.status).toBe("open"); + + // Complete payment + await completeInvoiceCheckout({ + url: finalizedInvoice.hosted_invoice_url!, + }); + + const customerAfter = await waitForCustomerInvoiceStatus({ + autumn: autumnV1, + customerId, + status: "paid", + }); + + // After payment - product active and credits granted + await expectProductActive({ + customer: customerAfter, + productId: oneOff.id, + }); + + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + balance: 100, + usage: 0, + }); + + // Verify invoice is paid + await expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 1, + latestTotal: preview.total, + latestStatus: "paid", + }); + }, +); diff --git a/server/tests/integration/billing/create-schedule/backdate/create-schedule-backdate-preview.test.ts b/server/tests/integration/billing/create-schedule/backdate/create-schedule-backdate-preview.test.ts index 57fc81c958..69445668fe 100644 --- a/server/tests/integration/billing/create-schedule/backdate/create-schedule-backdate-preview.test.ts +++ b/server/tests/integration/billing/create-schedule/backdate/create-schedule-backdate-preview.test.ts @@ -28,13 +28,13 @@ import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; -import { addMonths } from "date-fns"; +import { addMonths, addYears } from "date-fns"; import { createStripeCli } from "@/external/connect/createStripeCli"; -import { createAmountCoupon } from "../../utils/discounts/discountTestUtils"; import { expectResetAnchoredTo, getCustomerProduct, } from "../../attach/params/start-date/utils"; +import { createAmountCoupon } from "../../utils/discounts/discountTestUtils"; const previewCreateSchedule = async ({ autumnV1, @@ -271,3 +271,75 @@ test.concurrent( expect(response.invoice?.total).toBe(preview.total); }, ); + +test.concurrent( + `${chalk.yellowBright("create-schedule backdate preview: no proration still shows the forced subscription invoice and tax")}`, + async () => { + const customerId = "create-schedule-backdate-preview-no-proration-tax"; + const enterprise = products.base({ + id: "enterprise", + items: [items.annualPrice({ price: 8_400 })], + }); + const renewal = products.base({ + id: "enterprise-renewal", + items: [items.annualPrice({ price: 13_500 })], + }); + + const { autumnV1, advancedTo } = await initScenario({ + customerId, + setup: [ + s.platform.create({ + configOverrides: { automatic_tax: true }, + taxRegistrations: ["AU"], + }), + s.customer({ + testClock: false, + paymentMethod: "success", + stripeCustomerOverrides: { + address: { + country: "AU", + line1: "1 Test St", + city: "Sydney", + postal_code: "2000", + state: "NSW", + }, + }, + }), + s.products({ list: [enterprise, renewal] }), + ], + actions: [], + }); + + const startsAt = advancedTo - ms.days(260); + const params: CreateScheduleParamsV0Input = { + customer_id: customerId, + billing_behavior: "none", + phases: [ + { + starts_at: startsAt, + plans: [{ plan_id: enterprise.id }], + }, + { + starts_at: addYears(startsAt, 1).getTime(), + plans: [{ plan_id: renewal.id }], + }, + ], + }; + + const preview = await previewCreateSchedule({ autumnV1, params }); + + expect(preview.subtotal).toBe(8_400); + expect(preview.line_items).toHaveLength(1); + expect(preview.tax?.status).toBe("complete"); + expect(preview.tax?.total).toBeGreaterThan(0); + expect(preview.total).toBeCloseTo( + preview.subtotal + (preview.tax?.total ?? 0), + 2, + ); + + const response = await autumnV1.billing.createSchedule(params); + expect(response.invoice?.status).toBe("paid"); + expect(response.invoice?.total).toBeCloseTo(preview.total, 2); + }, + 300_000, +); diff --git a/server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode.test.ts b/server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode.test.ts index 81c412740f..e77110aebb 100644 --- a/server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode.test.ts +++ b/server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode.test.ts @@ -16,18 +16,19 @@ import { expect, test } from "bun:test"; import { type ApiCustomerV3, SuccessCode } from "@autumn/shared"; import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { waitForCustomerInvoiceStatus } from "@tests/integration/billing/utils/waitForCustomerInvoiceStatus"; import { expectSubCount, expectSubToBeCorrect, } from "@tests/merged/mergeUtils/expectSubCorrect"; import { TestFeature } from "@tests/setup/v2Features"; +import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2"; import { expectProductAttached, expectProductNotAttached, } from "@tests/utils/expectUtils/expectProductAttached"; import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; -import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2"; import ctx from "@tests/utils/testInitUtils/createTestContext"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; @@ -42,62 +43,69 @@ import chalk from "chalk"; * - Returns checkout_url (hosted invoice URL), product NOT active * - Complete checkout → product active */ -test.concurrent(`${chalk.yellowBright("legacy-inv-mode 1: new subscription")}`, async () => { - const customerId = "legacy-inv-mode-new"; - - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [messagesItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [], - }); - - const res = await autumnV1.attach({ - customer_id: customerId, - product_id: pro.id, - invoice: true, - }); - - expect(res.code).toBe(SuccessCode.CheckoutCreated); - expect(res.checkout_url).toBeDefined(); - - // Product should NOT be attached yet - const customerBefore = - await autumnV1.customers.get(customerId); - expect(customerBefore.features?.[TestFeature.Messages]).toBeUndefined(); - - await completeInvoiceCheckout({ url: res.checkout_url }); - - const customerAfter = await autumnV1.customers.get(customerId); - - expectProductAttached({ - customer: customerAfter as any, - product: pro, - }); - - expectCustomerFeatureCorrect({ - customer: customerAfter, - featureId: TestFeature.Messages, - includedUsage: 100, - balance: 100, - usage: 0, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); +test.concurrent( + `${chalk.yellowBright("legacy-inv-mode 1: new subscription")}`, + async () => { + const customerId = "legacy-inv-mode-new"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + invoice: true, + }); + + expect(res.code).toBe(SuccessCode.CheckoutCreated); + expect(res.checkout_url).toBeDefined(); + + // Product should NOT be attached yet + const customerBefore = + await autumnV1.customers.get(customerId); + expect(customerBefore.features?.[TestFeature.Messages]).toBeUndefined(); + + await completeInvoiceCheckout({ url: res.checkout_url }); + + const customerAfter = await waitForCustomerInvoiceStatus({ + autumn: autumnV1, + customerId, + status: "paid", + }); + + expectProductAttached({ + customer: customerAfter as any, + product: pro, + }); + + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 2: New subscription (merged / add-on) - invoice mode @@ -110,80 +118,87 @@ test.concurrent(`${chalk.yellowBright("legacy-inv-mode 1: new subscription")}`, * - Add-on NOT attached until payment completes * - Complete checkout → both products attached, merged sub correct */ -test.concurrent(`${chalk.yellowBright("legacy-inv-mode 2: merged add-on")}`, async () => { - const customerId = "legacy-inv-mode-merged"; - - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [messagesItem], - }); - - const addOnMessagesItem = items.monthlyMessages({ includedUsage: 200 }); - const addOnPriceItem = items.monthlyPrice({ price: 10 }); - const addOn = products.base({ - id: "monthly-addon", - isAddOn: true, - items: [addOnMessagesItem, addOnPriceItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, addOn] }), - ], - actions: [s.attach({ productId: pro.id })], - }); - - const res = await autumnV1.attach({ - customer_id: customerId, - product_id: addOn.id, - invoice: true, - }); - - expect(res.checkout_url).toBeDefined(); - - // Pro should still be attached, add-on should NOT be attached - const customerBefore = - await autumnV1.customers.get(customerId); - expectProductAttached({ - customer: customerBefore as any, - product: pro, - }); - expectProductNotAttached({ - customer: customerBefore as any, - product: addOn, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); - - await completeInvoiceCheckout({ url: res.checkout_url }); - - const customerAfter = await autumnV1.customers.get(customerId); - expectProductAttached({ - customer: customerAfter as any, - product: pro, - }); - expectProductAttached({ - customer: customerAfter as any, - product: addOn, - }); - - await expectSubCount({ ctx, customerId, count: 2 }); - // await expectSubToBeCorrect({ - // db: ctx.db, - // customerId, - // org: ctx.org, - // env: ctx.env, - - // }); -}); +test.concurrent( + `${chalk.yellowBright("legacy-inv-mode 2: merged add-on")}`, + async () => { + const customerId = "legacy-inv-mode-merged"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const addOnMessagesItem = items.monthlyMessages({ includedUsage: 200 }); + const addOnPriceItem = items.monthlyPrice({ price: 10 }); + const addOn = products.base({ + id: "monthly-addon", + isAddOn: true, + items: [addOnMessagesItem, addOnPriceItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addOn] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: addOn.id, + invoice: true, + }); + + expect(res.checkout_url).toBeDefined(); + + // Pro should still be attached, add-on should NOT be attached + const customerBefore = + await autumnV1.customers.get(customerId); + expectProductAttached({ + customer: customerBefore as any, + product: pro, + }); + expectProductNotAttached({ + customer: customerBefore as any, + product: addOn, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + + await completeInvoiceCheckout({ url: res.checkout_url }); + + const customerAfter = await waitForCustomerInvoiceStatus({ + autumn: autumnV1, + customerId, + status: "paid", + }); + expectProductAttached({ + customer: customerAfter as any, + product: pro, + }); + expectProductAttached({ + customer: customerAfter as any, + product: addOn, + }); + + await expectSubCount({ ctx, customerId, count: 2 }); + // await expectSubToBeCorrect({ + // db: ctx.db, + // customerId, + // org: ctx.org, + // env: ctx.env, + + // }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 3: Upgrade (pro → premium) - invoice mode @@ -196,133 +211,144 @@ test.concurrent(`${chalk.yellowBright("legacy-inv-mode 2: merged add-on")}`, asy * - Still on pro until payment completes * - Complete checkout → premium active, invoice paid */ -test.concurrent(`${chalk.yellowBright("legacy-inv-mode 3: upgrade")}`, async () => { - const customerId = "legacy-inv-mode-upgrade"; - - const proMessagesItem = items.monthlyMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [proMessagesItem], - }); - - const premiumMessagesItem = items.monthlyMessages({ - includedUsage: 500, - }); - const premium = products.premium({ - id: "premium", - items: [premiumMessagesItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [s.attach({ productId: pro.id })], - }); - - const res = await autumnV1.attach({ - customer_id: customerId, - product_id: premium.id, - invoice: true, - finalize_invoice: false, - enable_product_immediately: true, - }); - - expect(res.checkout_url).toBeFalsy(); - - const customerAfter = await autumnV1.customers.get(customerId); - expectProductAttached({ - customer: customerAfter as any, - product: premium, - }); - - expectCustomerFeatureCorrect({ - customer: customerAfter, - featureId: TestFeature.Messages, - includedUsage: 500, - balance: 500, - usage: 0, - }); - - // Invoice should be paid after checkout - const nonCachedCustomer = await autumnV1.customers.get( - customerId, - { skip_cache: "true" }, - ); - expect(nonCachedCustomer.invoices?.[0].status).toBe("draft"); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -test.concurrent(`${chalk.yellowBright("legacy-inv-mode 4: upgrade")}`, async () => { - const customerId = "legacy-inv-mode-upgrade-2"; - - const proMessagesItem = items.monthlyMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [proMessagesItem], - }); - - const premiumMessagesItem = items.monthlyMessages({ - includedUsage: 500, - }); - const premium = products.premium({ - id: "premium", - items: [premiumMessagesItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [s.attach({ productId: pro.id })], - }); - - const res = await autumnV1.attach({ - customer_id: customerId, - product_id: premium.id, - invoice: true, - enable_product_immediately: true, - }); - - expect(res.checkout_url).toBeDefined(); - - await completeInvoiceCheckout({ url: res.checkout_url }); - - const customerAfter = await autumnV1.customers.get(customerId); - expectProductAttached({ - customer: customerAfter as any, - product: premium, - }); - - expectCustomerFeatureCorrect({ - customer: customerAfter, - featureId: TestFeature.Messages, - includedUsage: 500, - balance: 500, - usage: 0, - }); - - // Invoice should be paid after checkout - const nonCachedCustomer = await autumnV1.customers.get( - customerId, - { skip_cache: "true" }, - ); - expect(nonCachedCustomer.invoices?.[0].status).toBe("paid"); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); +test.concurrent( + `${chalk.yellowBright("legacy-inv-mode 3: upgrade")}`, + async () => { + const customerId = "legacy-inv-mode-upgrade"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + const premiumMessagesItem = items.monthlyMessages({ + includedUsage: 500, + }); + const premium = products.premium({ + id: "premium", + items: [premiumMessagesItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + invoice: true, + finalize_invoice: false, + enable_product_immediately: true, + }); + + expect(res.checkout_url).toBeFalsy(); + + const customerAfter = + await autumnV1.customers.get(customerId); + expectProductAttached({ + customer: customerAfter as any, + product: premium, + }); + + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + }); + + // Invoice should be paid after checkout + const nonCachedCustomer = await autumnV1.customers.get( + customerId, + { skip_cache: "true" }, + ); + expect(nonCachedCustomer.invoices?.[0].status).toBe("draft"); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("legacy-inv-mode 4: upgrade")}`, + async () => { + const customerId = "legacy-inv-mode-upgrade-2"; + + const proMessagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [proMessagesItem], + }); + + const premiumMessagesItem = items.monthlyMessages({ + includedUsage: 500, + }); + const premium = products.premium({ + id: "premium", + items: [premiumMessagesItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + invoice: true, + enable_product_immediately: true, + }); + + expect(res.checkout_url).toBeDefined(); + + await completeInvoiceCheckout({ url: res.checkout_url }); + + const customerAfter = await waitForCustomerInvoiceStatus({ + autumn: autumnV1, + customerId, + status: "paid", + }); + expectProductAttached({ + customer: customerAfter as any, + product: premium, + }); + + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: 500, + balance: 500, + usage: 0, + }); + + // Invoice should be paid after checkout + const nonCachedCustomer = await autumnV1.customers.get( + customerId, + { skip_cache: "true" }, + ); + expect(nonCachedCustomer.invoices?.[0].status).toBe("paid"); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); diff --git a/server/tests/integration/billing/legacy/attach/invoice/payment-failure/legacy-attach-payment-failed.test.ts b/server/tests/integration/billing/legacy/attach/invoice/payment-failure/legacy-attach-payment-failed.test.ts index ea107fd937..b5a8cb34c5 100644 --- a/server/tests/integration/billing/legacy/attach/invoice/payment-failure/legacy-attach-payment-failed.test.ts +++ b/server/tests/integration/billing/legacy/attach/invoice/payment-failure/legacy-attach-payment-failed.test.ts @@ -17,15 +17,16 @@ import { expect, test } from "bun:test"; import { type ApiCustomerV3, OnIncrease, SuccessCode } from "@autumn/shared"; import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { waitForCustomerInvoiceStatus } from "@tests/integration/billing/utils/waitForCustomerInvoiceStatus"; import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; import { TestFeature } from "@tests/setup/v2Features"; +import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2"; import { expectProductAttached, expectProductNotAttached, } from "@tests/utils/expectUtils/expectProductAttached"; import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; -import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2"; import ctx from "@tests/utils/testInitUtils/createTestContext"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; @@ -40,48 +41,58 @@ import chalk from "chalk"; * - Returns checkout_url * - Product NOT active (features undefined) */ -test.concurrent(`${chalk.yellowBright("legacy-fail 1: new subscription")}`, async () => { - const customerId = "legacy-fail-new"; - - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [messagesItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [s.customer({ paymentMethod: "fail" }), s.products({ list: [pro] })], - actions: [], - }); - - const res = await autumnV1.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - expect(res.checkout_url).toBeDefined(); - - const customer = await autumnV1.customers.get(customerId); - expect(customer.features?.[TestFeature.Messages]).toBeUndefined(); - - await completeInvoiceCheckout({ - url: res.checkout_url, - }); - - const customerAfter = await autumnV1.customers.get(customerId); - expectProductAttached({ - customer: customerAfter as any, - product: pro, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); +test.concurrent( + `${chalk.yellowBright("legacy-fail 1: new subscription")}`, + async () => { + const customerId = "legacy-fail-new"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "fail" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + expect(res.checkout_url).toBeDefined(); + + const customer = await autumnV1.customers.get(customerId); + expect(customer.features?.[TestFeature.Messages]).toBeUndefined(); + + await completeInvoiceCheckout({ + url: res.checkout_url, + }); + + const customerAfter = await waitForCustomerInvoiceStatus({ + autumn: autumnV1, + customerId, + status: "paid", + }); + expectProductAttached({ + customer: customerAfter as any, + product: pro, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 2: Upgrade (pro → premium) - payment failed @@ -154,47 +165,50 @@ test.concurrent(`${chalk.yellowBright("legacy-fail 2: upgrade")}`, async () => { * - Attach monthly add-on → checkout_url * - Pro still attached, add-on NOT attached */ -test.concurrent(`${chalk.yellowBright("legacy-fail 3: merged add-on")}`, async () => { - const customerId = "legacy-fail-merged"; - - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [messagesItem], - }); - - const addOnMessagesItem = items.monthlyMessages({ includedUsage: 200 }); - const addOnPriceItem = items.monthlyPrice({ price: 10 }); - const addOn = products.base({ - id: "monthly-addon", - isAddOn: true, - items: [addOnMessagesItem, addOnPriceItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, addOn] }), - ], - actions: [ - s.attach({ productId: pro.id }), - s.attachPaymentMethod({ type: "fail" }), - ], - }); - - const res = await autumnV1.attach({ - customer_id: customerId, - product_id: addOn.id, - }); - - expect(res.checkout_url).toBeDefined(); - - // Pro still attached, add-on NOT attached - const customer = await autumnV1.customers.get(customerId); - expectProductAttached({ customer: customer as any, product: pro }); - expectProductNotAttached({ customer: customer as any, product: addOn }); -}); +test.concurrent( + `${chalk.yellowBright("legacy-fail 3: merged add-on")}`, + async () => { + const customerId = "legacy-fail-merged"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const addOnMessagesItem = items.monthlyMessages({ includedUsage: 200 }); + const addOnPriceItem = items.monthlyPrice({ price: 10 }); + const addOn = products.base({ + id: "monthly-addon", + isAddOn: true, + items: [addOnMessagesItem, addOnPriceItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addOn] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.attachPaymentMethod({ type: "fail" }), + ], + }); + + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: addOn.id, + }); + + expect(res.checkout_url).toBeDefined(); + + // Pro still attached, add-on NOT attached + const customer = await autumnV1.customers.get(customerId); + expectProductAttached({ customer: customer as any, product: pro }); + expectProductNotAttached({ customer: customer as any, product: addOn }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 4: Update quantity (prepaid increase) - payment failed @@ -207,75 +221,79 @@ test.concurrent(`${chalk.yellowBright("legacy-fail 3: merged add-on")}`, async ( * - Increase quantity to 500 → checkout_url * - Balance unchanged (still at original total) */ -test.concurrent(`${chalk.yellowBright("legacy-fail 4: update quantity")}`, async () => { - const customerId = "legacy-fail-qty"; - - const prepaidItem = items.prepaidMessages({ - includedUsage: 100, - billingUnits: 100, - price: 10, - config: { - on_increase: OnIncrease.ProrateImmediately, - }, - }); - const pro = products.pro({ - id: "pro", - items: [prepaidItem], - }); - - // V1 quantity excludes allowance: 300 units = 3 packs - const initialQuantityV1 = 300; - const initialTotalBalance = 100 + initialQuantityV1; // 400 - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - options: [ - { - feature_id: TestFeature.Messages, - quantity: initialQuantityV1, - }, - ], - }), - s.attachPaymentMethod({ type: "fail" }), - ], - }); - - // Verify initial state - const customerInit = await autumnV1.customers.get(customerId); - expectCustomerFeatureCorrect({ - customer: customerInit, - featureId: TestFeature.Messages, - includedUsage: initialTotalBalance, - balance: initialTotalBalance, - usage: 0, - }); - - // Increase quantity to 500 (V1, excludes allowance) - const res = await autumnV1.attach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: 500 }], - }); - - expect(res.checkout_url).toBeDefined(); - - // Balance should be unchanged - const customer = await autumnV1.customers.get(customerId); - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: initialTotalBalance, - balance: initialTotalBalance, - usage: 0, - }); -}); +test.concurrent( + `${chalk.yellowBright("legacy-fail 4: update quantity")}`, + async () => { + const customerId = "legacy-fail-qty"; + + const prepaidItem = items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 10, + config: { + on_increase: OnIncrease.ProrateImmediately, + }, + }); + const pro = products.pro({ + id: "pro", + items: [prepaidItem], + }); + + // V1 quantity excludes allowance: 300 units = 3 packs + const initialQuantityV1 = 300; + const initialTotalBalance = 100 + initialQuantityV1; // 400 + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: initialQuantityV1, + }, + ], + }), + s.attachPaymentMethod({ type: "fail" }), + ], + }); + + // Verify initial state + const customerInit = + await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer: customerInit, + featureId: TestFeature.Messages, + includedUsage: initialTotalBalance, + balance: initialTotalBalance, + usage: 0, + }); + + // Increase quantity to 500 (V1, excludes allowance) + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 500 }], + }); + + expect(res.checkout_url).toBeDefined(); + + // Balance should be unchanged + const customer = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: initialTotalBalance, + balance: initialTotalBalance, + usage: 0, + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 5: New subscription - payment failed, recover via invoice checkout @@ -290,52 +308,63 @@ test.concurrent(`${chalk.yellowBright("legacy-fail 4: update quantity")}`, async * * Migrated from: invoice-action-required/new-subscription/new-subscription-action-required1.test.ts */ -test.concurrent(`${chalk.yellowBright("legacy-fail 5: new subscription, recover via invoice checkout")}`, async () => { - const customerId = "legacy-fail-recover-new"; - - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [messagesItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [s.customer({ paymentMethod: "fail" }), s.products({ list: [pro] })], - actions: [], - }); - - const res = await autumnV1.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - expect(res.code).toBe(SuccessCode.InvoiceActionRequired); - expect(res.checkout_url).toBeDefined(); - expect(res.checkout_url).toContain("invoice.stripe.com"); - expect(res.message).toBe("Payment action required"); - - await completeInvoiceCheckout({ url: res.checkout_url }); - - // Product should be active after completing invoice checkout - const customer = await autumnV1.customers.get(customerId); - expectProductAttached({ customer: customer as any, product: pro }); - - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: 100, - balance: 100, - usage: 0, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}, 120000); +test.concurrent( + `${chalk.yellowBright("legacy-fail 5: new subscription, recover via invoice checkout")}`, + async () => { + const customerId = "legacy-fail-recover-new"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "fail" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + expect(res.code).toBe(SuccessCode.InvoiceActionRequired); + expect(res.checkout_url).toBeDefined(); + expect(res.checkout_url).toContain("invoice.stripe.com"); + expect(res.message).toBe("Payment action required"); + + await completeInvoiceCheckout({ url: res.checkout_url }); + + // Product should be active after completing invoice checkout + const customer = await waitForCustomerInvoiceStatus({ + autumn: autumnV1, + customerId, + status: "paid", + }); + expectProductAttached({ customer: customer as any, product: pro }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, + 120000, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 6: One-off product - payment failed, recover via invoice checkout @@ -349,47 +378,54 @@ test.concurrent(`${chalk.yellowBright("legacy-fail 5: new subscription, recover * - Complete invoice checkout (Puppeteer enters good card) * - Product IS active, features correct */ -test.concurrent(`${chalk.yellowBright("legacy-fail 6: one-off product, recover via invoice checkout")}`, async () => { - const customerId = "legacy-fail-recover-oneoff"; - - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const oneOff = products.oneOff({ - id: "one-off", - items: [messagesItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "fail" }), - s.products({ list: [oneOff] }), - ], - actions: [], - }); - - const res = await autumnV1.attach({ - customer_id: customerId, - product_id: oneOff.id, - }); - - expect(res.checkout_url).toBeDefined(); - - // Product should NOT be active yet - const customerBefore = - await autumnV1.customers.get(customerId); - expect(customerBefore.features?.[TestFeature.Messages]).toBeUndefined(); - - await completeInvoiceCheckout({ url: res.checkout_url }); - - // Product should be active after completing invoice checkout - const customerAfter = await autumnV1.customers.get(customerId); - expectProductAttached({ customer: customerAfter as any, product: oneOff }); - - expectCustomerFeatureCorrect({ - customer: customerAfter, - featureId: TestFeature.Messages, - includedUsage: 100, - balance: 100, - usage: 0, - }); -}); +test.concurrent( + `${chalk.yellowBright("legacy-fail 6: one-off product, recover via invoice checkout")}`, + async () => { + const customerId = "legacy-fail-recover-oneoff"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const oneOff = products.oneOff({ + id: "one-off", + items: [messagesItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "fail" }), + s.products({ list: [oneOff] }), + ], + actions: [], + }); + + const res = await autumnV1.attach({ + customer_id: customerId, + product_id: oneOff.id, + }); + + expect(res.checkout_url).toBeDefined(); + + // Product should NOT be active yet + const customerBefore = + await autumnV1.customers.get(customerId); + expect(customerBefore.features?.[TestFeature.Messages]).toBeUndefined(); + + await completeInvoiceCheckout({ url: res.checkout_url }); + + // Product should be active after completing invoice checkout + const customerAfter = await waitForCustomerInvoiceStatus({ + autumn: autumnV1, + customerId, + status: "paid", + }); + expectProductAttached({ customer: customerAfter as any, product: oneOff }); + + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); + }, +); diff --git a/server/tests/integration/billing/legacy/attach/new/legacy-new-oneoff-zero-decimal.test.ts b/server/tests/integration/billing/legacy/attach/new/legacy-new-oneoff-zero-decimal.test.ts index 1e6243e2b5..bc8c42118d 100644 --- a/server/tests/integration/billing/legacy/attach/new/legacy-new-oneoff-zero-decimal.test.ts +++ b/server/tests/integration/billing/legacy/attach/new/legacy-new-oneoff-zero-decimal.test.ts @@ -11,8 +11,8 @@ */ import { test } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js"; +import { waitForCustomerInvoiceStatus } from "@tests/integration/billing/utils/waitForCustomerInvoiceStatus.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; @@ -59,7 +59,11 @@ test(`${chalk.yellowBright("legacy one-off rwf: prepaid one-off charges major un options: [{ feature_id: TestFeature.Messages, quantity: 1 }], }); - const customer = await autumnV1.customers.get(customerId); + const customer = await waitForCustomerInvoiceStatus({ + autumn: autumnV1, + customerId, + status: "paid", + }); // 23,188 RWF prepaid item + 10 RWF product base price await expectCustomerInvoiceCorrect({ diff --git a/server/tests/integration/billing/migrations-v2/run-handler/run-handler-lazy-run-body.test.ts b/server/tests/integration/billing/migrations-v2/run-handler/run-handler-lazy-run-body.test.ts index deeb7336aa..100fa18a5e 100644 --- a/server/tests/integration/billing/migrations-v2/run-handler/run-handler-lazy-run-body.test.ts +++ b/server/tests/integration/billing/migrations-v2/run-handler/run-handler-lazy-run-body.test.ts @@ -12,6 +12,7 @@ * background-only shape (`lazy_run = false`). * - The response echoes the requested `lazy_run` value alongside * `dry_run` and `run_id`. + * - Legacy concurrency input is accepted but effective concurrency is `1`. */ import { expect, test } from "bun:test"; @@ -77,7 +78,7 @@ test.concurrent( expect(response.migration_id).toBe(migration.id); expect(response.lazy_run).toBe(true); - expect(response.concurrency).toBe(7); + expect(response.concurrency).toBe(1); const updatedMigration = await migrationRepo.find({ ctx, diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/pooled-balances/update-plan-pooled-balance.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/pooled-balances/update-plan-pooled-balance.test.ts new file mode 100644 index 0000000000..802891f961 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/pooled-balances/update-plan-pooled-balance.test.ts @@ -0,0 +1,65 @@ +/** Migration update_plan inherits pooled transitions from updateSubscription. */ + +/** Contract: unrelated item updates preserve the pool and its contribution identity. */ + +/** Contract: 100 -> 200 pooled messages adds only the grant delta while retaining usage. */ + +/** Contract: non-pooled -> pooled messages creates one contribution with carried usage. */ + +import { test } from "bun:test"; +import { runUpdatePlanMigration } from "@tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.js"; +import { + buildPooledMigrationCustomization, + expectPooledCustomizationResult, + type PooledCustomizationCase, + setupPooledCustomizationScenario, +} from "@tests/integration/billing/pooled-balances/utils/pooledBalanceCustomizationTestUtils.js"; +import chalk from "chalk"; + +const migrationCases: Array<{ + id: PooledCustomizationCase; + label: string; +}> = [ + { id: "unrelated_item", label: "unrelated item preserves the pool" }, + { id: "increase_grant", label: "pooled grant increase applies the delta" }, + { id: "enable_pooling", label: "private to pooled carries usage" }, +]; + +for (const migrationCase of migrationCases) { + test.concurrent( + `${chalk.yellowBright(`pooled migration update_plan: ${migrationCase.label}`)}`, + async () => { + const customerId = `pooled-migration-${migrationCase.id}`; + const scenario = await setupPooledCustomizationScenario({ + customerId, + case: migrationCase.id, + }); + + await runUpdatePlanMigration({ + ctx: scenario.ctx, + migrationClient: scenario.autumnV2_2, + migrationId: `${customerId}-migration`, + customerId, + filter: { customer: { plan: { plan_id: scenario.plan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: scenario.plan.id }, + customize: buildPooledMigrationCustomization({ + case: migrationCase.id, + }), + }, + ], + }, + runOnServer: false, + }); + + await expectPooledCustomizationResult({ + scenario, + case: migrationCase.id, + surface: "migration", + }); + }, + ); +} diff --git a/server/tests/integration/billing/pooled-balances/attach/downgrade-pooled-balance.test.ts b/server/tests/integration/billing/pooled-balances/attach/downgrade-pooled-balance.test.ts new file mode 100644 index 0000000000..761ab71d0b --- /dev/null +++ b/server/tests/integration/billing/pooled-balances/attach/downgrade-pooled-balance.test.ts @@ -0,0 +1,213 @@ +/** Scheduled attach downgrades replace pooled contributions without replaying usage. */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + type ApiEntityV2, + type AttachParamsV1Input, + EntInterval, + PooledBalanceResetMode, +} from "@autumn/shared"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect/index.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { expectPooledBalanceCorrect } from "../utils/expectPooledBalanceCorrect.js"; +import { expectPooledSourceContributionsHydrated } from "../utils/expectPooledSourceContributionsHydrated"; +import { getPooledSourceCustomerProduct } from "../utils/getPooledBalanceDbState.js"; +import { waitForPooledBalanceCorrect } from "../utils/waitForPooledBalanceCorrect.js"; + +const USAGE = 50; +const MONTHLY_POOL_LIFECYCLE = { + interval: EntInterval.Month, + nextResetAt: "present", + resetCycleAnchor: "present", + resetMode: PooledBalanceResetMode.Subscription, + stripeSubscriptionId: "stripe_subscription", +} as const; + +const runDowngradeCase = async ({ + id, + premiumGrant, + proGrant, +}: { + id: string; + premiumGrant: number; + proGrant: number; +}) => { + const customerId = `pooled-downgrade-${id}`; + const premium = products.premium({ + id: `pooled-premium-${id}`, + items: [ + { + ...items.monthlyMessages({ includedUsage: premiumGrant }), + pooled: true, + }, + ], + }); + const pro = products.pro({ + id: `pooled-pro-${id}`, + items: [ + { + ...items.monthlyMessages({ includedUsage: proGrant }), + pooled: true, + }, + ], + }); + const { entities, autumnV2_2, ctx, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.entities({ count: 3, featureId: TestFeature.Users }), + s.products({ list: [premium, pro] }), + ], + actions: [ + s.billing.attach({ productId: premium.id, entityIndex: 0 }), + s.billing.attach({ productId: pro.id, entityIndex: 1 }), + s.track({ + featureId: TestFeature.Messages, + value: USAGE, + entityIndex: 2, + timeout: 2000, + }), + ], + }); + + const initialGrant = premiumGrant + proGrant; + const beforeDowngrade = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: initialGrant - USAGE, + adjustment: 0, + granted: initialGrant, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { count: 2 }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + const outgoingCustomerProduct = getPooledSourceCustomerProduct({ + state: beforeDowngrade, + productId: premium.id, + entityId: entities[0].id, + }); + await expectPooledSourceContributionsHydrated({ + ctx, + customerId, + contributions: beforeDowngrade.contributions, + }); + + await autumnV2_2.billing.attach({ + customer_id: customerId, + entity_id: entities[0].id, + plan_id: pro.id, + redirect_mode: "if_required", + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + + const entityBeforeTransition = await autumnV2_2.entities.get( + customerId, + entities[0].id, + { skip_cache: "true" }, + ); + await expectCustomerProducts({ + customer: entityBeforeTransition, + canceling: [premium.id], + scheduled: [pro.id], + }); + const scheduledState = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: initialGrant - USAGE, + adjustment: 0, + granted: initialGrant, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { count: 2 }, + sources: { count: 3 }, + }); + const incomingCustomerProduct = getPooledSourceCustomerProduct({ + state: scheduledState, + productId: pro.id, + entityId: entities[0].id, + }); + expect( + scheduledState.contributions.some( + (contribution) => + contribution.source_customer_product_id === incomingCustomerProduct.id, + ), + ).toBe(false); + + if (!testClockId) throw new Error("Expected a Stripe test clock"); + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId, + currentEpochMs: advancedTo, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + + const finalGrant = proGrant * 2; + await waitForPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: finalGrant, + adjustment: 0, + granted: finalGrant, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { + count: 2, + currentContribution: proGrant, + nextCycleContribution: proGrant, + excludedSourceCustomerProductIds: [outgoingCustomerProduct.id], + }, + sources: { count: 3, balance: 0, adjustment: 0 }, + }); + + const entityAfterTransition = await autumnV2_2.entities.get( + customerId, + entities[0].id, + { skip_cache: "true" }, + ); + await expectCustomerProducts({ + customer: entityAfterTransition, + active: [pro.id], + notPresent: [premium.id], + }); + const customerAfterTransition = await autumnV2_2.customers.get( + customerId, + { skip_cache: "true" }, + ); + expectBalanceCorrect({ + customer: customerAfterTransition, + featureId: TestFeature.Messages, + granted: finalGrant, + includedGrant: finalGrant, + remaining: finalGrant, + usage: 0, + planId: null, + breakdownCount: 1, + }); +}; + +test.concurrent( + `${chalk.yellowBright("pooled attach: equal-grant downgrade preserves the renewed shared balance")}`, + async () => { + await runDowngradeCase({ id: "equal", premiumGrant: 100, proGrant: 100 }); + }, +); + +test.concurrent( + `${chalk.yellowBright("pooled attach: different-grant downgrade applies the delta to the renewed shared balance")}`, + async () => { + await runDowngradeCase({ id: "delta", premiumGrant: 200, proGrant: 100 }); + }, +); diff --git a/server/tests/integration/billing/pooled-balances/attach/upgrade-pooled-balance-with-rollover.test.ts b/server/tests/integration/billing/pooled-balances/attach/upgrade-pooled-balance-with-rollover.test.ts new file mode 100644 index 0000000000..0bd256eaaa --- /dev/null +++ b/server/tests/integration/billing/pooled-balances/attach/upgrade-pooled-balance-with-rollover.test.ts @@ -0,0 +1,36 @@ +/** After subscription renewal creates a capped pooled rollover, Pro → Enterprise preserves that rollover. + * The upgrade applies only the contribution delta and leaves the customer-level prepaid add-on unchanged. */ + +import { test } from "bun:test"; +import type { AttachParamsV1Input } from "@autumn/shared"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect/index.js"; +import chalk from "chalk"; +import { + expectPooledRolloverTransitionCorrect, + setupPooledRolloverTransitionScenario, +} from "../utils/pooledBalanceRolloverTransitionTestUtils.js"; + +test.concurrent( + `${chalk.yellowBright("pooled rollover attach: Pro to Enterprise preserves rollover and prepaid add-on")}`, + async () => { + const scenario = await setupPooledRolloverTransitionScenario({ + customerId: "pooled-rollover-upgrade-enterprise", + }); + + await scenario.autumnV2_2.billing.attach({ + customer_id: scenario.customerId, + entity_id: scenario.entities[0].id, + plan_id: scenario.enterprise.id, + redirect_mode: "if_required", + }); + await expectStripeSubscriptionCorrect({ + ctx: scenario.ctx, + customerId: scenario.customerId, + }); + + await expectPooledRolloverTransitionCorrect({ + scenario, + expectedProductId: scenario.enterprise.id, + }); + }, +); diff --git a/server/tests/integration/billing/pooled-balances/checkout/pooled-balance-checkout-attach.test.ts b/server/tests/integration/billing/pooled-balances/checkout/pooled-balance-checkout-attach.test.ts new file mode 100644 index 0000000000..9035707e3e --- /dev/null +++ b/server/tests/integration/billing/pooled-balances/checkout/pooled-balance-checkout-attach.test.ts @@ -0,0 +1,244 @@ +/** + * TDD contract for pooled balances created through a Stripe checkout session, + * then incremented by subsequent direct (immediate-charge) entity attaches. + * + * Contract under test: + * New behaviors: + * - An entity-scoped attach that routes through checkout creates the pooled + * balance graph when `checkout.session.completed` replays the deferred + * billing plan — not only on the direct attach path. + * - The pool created by checkout is keyed to the real Stripe subscription + * (not the placeholder used before the subscription exists). + * - A second entity attaching the same plan with a card on file joins the + * SAME pool and increments `granted` / balance by its own grant. + * - A third entity increments again — contributions stay one-per-source. + * - Usage tracked on any entity draws down the shared pool. + * Side effects: + * - 1 `pooled_balances` row + 1 synthetic `customer_entitlements` row + * (`is_pooled_balance = true`, `customer_product_id = null`). + * - 1 `pooled_balance_contributions` row per source customer entitlement. + * - Each source customer entitlement normalized to balance 0. + * + * Pre-impl red: the checkout-completed replay leaves the pooled source + * entitlement un-pooled, so no pool row exists and the feature disappears from + * the customer's balances entirely (pooled sources are hidden from the public + * balance list). + * Post-impl green: pool exists after checkout and increments per entity. + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + type AttachParamsV1Input, + EntInterval, + PooledBalanceResetMode, +} from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { completeStripeCheckoutFormV2 } from "@tests/utils/browserPool/completeStripeCheckoutFormV2.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { expectPooledBalanceCorrect } from "../utils/expectPooledBalanceCorrect.js"; + +const POOLED_GRANT = 100; +const USAGE = 30; +const CHECKOUT_WEBHOOK_MS = 12_000; + +const SUBSCRIPTION_POOL_LIFECYCLE = { + interval: EntInterval.Month, + nextResetAt: "present", + resetCycleAnchor: "present", + resetMode: PooledBalanceResetMode.Subscription, + stripeSubscriptionId: "stripe_subscription", +} as const; + +test( + chalk.yellowBright( + "pooled checkout: checkout session creates the pool, later entities increment it", + ), + async () => { + const customerId = "pooled-checkout-attach"; + const pooledPlan = products.pro({ + id: "pooled-checkout-plan", + items: [ + { + ...items.monthlyMessages({ includedUsage: POOLED_GRANT }), + pooled: true, + }, + ], + }); + + // No payment method on the customer — the first attach must route + // through a Stripe checkout session. + const { autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.entities({ count: 3, featureId: TestFeature.Users }), + s.products({ list: [pooledPlan] }), + ], + actions: [], + }); + + // ── Contract 1: entity 1 attaches via checkout ────────────────────── + // Pre-fix: checkout completes, cusProduct is Active, but no pooled_balances + // row exists and Messages vanishes from the customer's balances. + // Post-fix: one pool at granted = POOLED_GRANT, keyed to the real sub. + const checkoutResult = await autumnV2_2.billing.attach( + { + customer_id: customerId, + entity_id: entities[0].id, + plan_id: pooledPlan.id, + }, + { timeout: 0 }, + ); + expect(checkoutResult.payment_url).toContain("checkout.stripe.com"); + + await completeStripeCheckoutFormV2({ url: checkoutResult.payment_url! }); + await timeout(CHECKOUT_WEBHOOK_MS); + + const afterCheckout = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: POOLED_GRANT, + adjustment: 0, + granted: POOLED_GRANT, + ...SUBSCRIPTION_POOL_LIFECYCLE, + }, + contributions: { + count: 1, + currentContribution: POOLED_GRANT, + nextCycleContribution: POOLED_GRANT, + }, + sources: { count: 1, balance: 0, adjustment: 0 }, + }); + const pooledCustomerEntitlement = afterCheckout.poolCustomerEntitlements[0]; + + const customerAfterCheckout = await autumnV2_2.customers.get( + customerId, + { + skip_cache: "true", + }, + ); + expectBalanceCorrect({ + customer: customerAfterCheckout, + featureId: TestFeature.Messages, + granted: POOLED_GRANT, + includedGrant: POOLED_GRANT, + remaining: POOLED_GRANT, + usage: 0, + breakdownCount: 1, + breakdownId: pooledCustomerEntitlement.id, + }); + + // ── Contract 2: entity 2 attaches with the card saved by checkout ─── + // Immediate charge, no redirect — must join the SAME pool. + const secondAttach = await autumnV2_2.billing.attach({ + customer_id: customerId, + entity_id: entities[1].id, + plan_id: pooledPlan.id, + }); + expect(secondAttach.payment_url).toBeFalsy(); + + await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: POOLED_GRANT * 2, + adjustment: 0, + granted: POOLED_GRANT * 2, + ...SUBSCRIPTION_POOL_LIFECYCLE, + }, + contributions: { + count: 2, + currentContribution: POOLED_GRANT, + nextCycleContribution: POOLED_GRANT, + }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + + // ── Contract 3: entity 3 increments again ─────────────────────────── + await autumnV2_2.billing.attach({ + customer_id: customerId, + entity_id: entities[2].id, + plan_id: pooledPlan.id, + }); + + await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: POOLED_GRANT * 3, + adjustment: 0, + granted: POOLED_GRANT * 3, + ...SUBSCRIPTION_POOL_LIFECYCLE, + }, + contributions: { + count: 3, + currentContribution: POOLED_GRANT, + nextCycleContribution: POOLED_GRANT, + }, + sources: { count: 3, balance: 0, adjustment: 0 }, + }); + + const customerAfterThirdAttach = + await autumnV2_2.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: customerAfterThirdAttach, + featureId: TestFeature.Messages, + granted: POOLED_GRANT * 3, + includedGrant: POOLED_GRANT * 3, + remaining: POOLED_GRANT * 3, + usage: 0, + breakdownCount: 1, + breakdownId: pooledCustomerEntitlement.id, + }); + + // ── Contract 4: usage on the checkout-created entity draws the pool ── + await autumnV2_2.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: USAGE, + }); + await timeout(2000); + + await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: POOLED_GRANT * 3 - USAGE, + adjustment: 0, + granted: POOLED_GRANT * 3, + ...SUBSCRIPTION_POOL_LIFECYCLE, + }, + contributions: { + count: 3, + currentContribution: POOLED_GRANT, + nextCycleContribution: POOLED_GRANT, + }, + sources: { count: 3, balance: 0, adjustment: 0 }, + }); + + const customerAfterUsage = await autumnV2_2.customers.get( + customerId, + { skip_cache: "true" }, + ); + expectBalanceCorrect({ + customer: customerAfterUsage, + featureId: TestFeature.Messages, + granted: POOLED_GRANT * 3, + includedGrant: POOLED_GRANT * 3, + remaining: POOLED_GRANT * 3 - USAGE, + usage: USAGE, + breakdownCount: 1, + breakdownId: pooledCustomerEntitlement.id, + }); + }, +); diff --git a/server/tests/integration/billing/pooled-balances/create-schedule/create-schedule-pooled-balances.test.ts b/server/tests/integration/billing/pooled-balances/create-schedule/create-schedule-pooled-balances.test.ts new file mode 100644 index 0000000000..ade3187afd --- /dev/null +++ b/server/tests/integration/billing/pooled-balances/create-schedule/create-schedule-pooled-balances.test.ts @@ -0,0 +1,412 @@ +/** Entity schedules reconcile immediate in-scope products; future products and unrelated pools or prepaid remain untouched. + * Customer schedules leave every entity-scoped pooled contribution unchanged. */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + CusProductStatus, + EntInterval, + ms, + PooledBalanceResetMode, +} from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { expectPooledBalanceCorrect } from "../utils/expectPooledBalanceCorrect.js"; +import { + getPooledSourceCustomerProduct, + type PooledBalanceDbState, +} from "../utils/getPooledBalanceDbState.js"; + +const USAGE = 50; +const MONTHLY_LAZY_POOL = { + interval: EntInterval.Month, + nextResetAt: "present", + resetCycleAnchor: "present", + resetMode: PooledBalanceResetMode.Lazy, + stripeSubscriptionId: null, +} as const; + +const pooledMessagesPlan = ({ + id, + grant, + group, +}: { + id: string; + grant: number; + group: string; +}) => + products.base({ + id, + group, + items: [ + { + ...items.monthlyMessages({ includedUsage: grant }), + pooled: true, + }, + ], + }); + +const getContributionForSource = ({ + state, + sourceCustomerProductId, +}: { + state: PooledBalanceDbState; + sourceCustomerProductId: string; +}) => { + const contribution = state.contributions.find( + (candidate) => + candidate.source_customer_product_id === sourceCustomerProductId, + ); + if (!contribution) { + throw new Error( + `Missing pooled contribution for '${sourceCustomerProductId}'.`, + ); + } + return contribution; +}; + +const getCustomerLevelCustomerProduct = ({ + state, + productId, +}: { + state: PooledBalanceDbState; + productId: string; +}) => { + const customerProduct = state.sourceCustomerProducts.find( + (candidate) => + candidate.product_id === productId && candidate.entity_id === null, + ); + if (!customerProduct) { + throw new Error(`Missing customer-level product '${productId}'.`); + } + return customerProduct; +}; + +test.concurrent( + `${chalk.yellowBright("pooled create-schedule: entity wholesale replacement preserves unrelated sources")}`, + async () => { + const customerId = "pooled-create-schedule-entity"; + const outgoingA = pooledMessagesPlan({ + id: "pooled-schedule-outgoing-a", + grant: 100, + group: "primary", + }); + const outgoingB = pooledMessagesPlan({ + id: "pooled-schedule-outgoing-b", + grant: 200, + group: "secondary", + }); + const incomingA = pooledMessagesPlan({ + id: "pooled-schedule-incoming-a", + grant: 150, + group: "primary", + }); + const incomingB = pooledMessagesPlan({ + id: "pooled-schedule-incoming-b", + grant: 250, + group: "secondary", + }); + const futureA = pooledMessagesPlan({ + id: "pooled-schedule-future-a", + grant: 300, + group: "primary", + }); + const futureB = pooledMessagesPlan({ + id: "pooled-schedule-future-b", + grant: 400, + group: "secondary", + }); + const prepaidCredits = products.oneOffAddOn({ + id: "pooled-schedule-prepaid-credits", + items: [items.oneOffWords({ includedUsage: 75 })], + }); + + const { entities, autumnV2_2, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.entities({ count: 3, featureId: TestFeature.Users }), + s.products({ + list: [ + outgoingA, + outgoingB, + incomingA, + incomingB, + futureA, + futureB, + prepaidCredits, + ], + }), + ], + actions: [ + s.billing.attach({ productId: prepaidCredits.id }), + s.billing.attach({ productId: outgoingA.id, entityIndex: 0 }), + s.billing.attach({ productId: outgoingB.id, entityIndex: 0 }), + s.billing.attach({ productId: outgoingA.id, entityIndex: 1 }), + s.track({ + featureId: TestFeature.Messages, + value: USAGE, + entityIndex: 2, + timeout: 2000, + }), + ], + }); + + const before = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: 350, + adjustment: 0, + granted: 400, + ...MONTHLY_LAZY_POOL, + }, + contributions: { count: 3 }, + sources: { count: 3, balance: 0, adjustment: 0 }, + }); + const unrelatedCustomerProduct = getPooledSourceCustomerProduct({ + state: before, + productId: outgoingA.id, + entityId: entities[1]!.id, + }); + const unrelatedContribution = getContributionForSource({ + state: before, + sourceCustomerProductId: unrelatedCustomerProduct.id, + }); + const outgoingCustomerProductIds = [outgoingA, outgoingB].map( + (product) => + getPooledSourceCustomerProduct({ + state: before, + productId: product.id, + entityId: entities[0]!.id, + }).id, + ); + const prepaidCustomerProduct = getCustomerLevelCustomerProduct({ + state: before, + productId: prepaidCredits.id, + }); + const prepaidEntitlement = + prepaidCustomerProduct.customer_entitlements.find( + (customerEntitlement) => + customerEntitlement.feature_id === TestFeature.Words, + ); + expect(prepaidEntitlement?.balance).toBeGreaterThan(0); + + await autumnV2_2.billing.createSchedule({ + customer_id: customerId, + entity_id: entities[0]!.id, + phases: [ + { + starts_at: advancedTo, + plans: [{ plan_id: incomingA.id }, { plan_id: incomingB.id }], + }, + { + starts_at: advancedTo + ms.days(30), + plans: [{ plan_id: futureA.id }, { plan_id: futureB.id }], + }, + ], + }); + + const after = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: 450, + adjustment: 0, + granted: 500, + ...MONTHLY_LAZY_POOL, + }, + contributions: { + count: 3, + excludedSourceCustomerProductIds: outgoingCustomerProductIds, + }, + sources: { count: 7 }, + }); + const preservedContribution = getContributionForSource({ + state: after, + sourceCustomerProductId: unrelatedCustomerProduct.id, + }); + expect(preservedContribution.id).toBe(unrelatedContribution.id); + expect(preservedContribution).toMatchObject({ + current_contribution: 100, + next_cycle_contribution: 100, + }); + + for (const [product, expectedContribution] of [ + [incomingA, 150], + [incomingB, 250], + ] as const) { + const customerProduct = getPooledSourceCustomerProduct({ + state: after, + productId: product.id, + entityId: entities[0]!.id, + }); + expect( + getContributionForSource({ + state: after, + sourceCustomerProductId: customerProduct.id, + }), + ).toMatchObject({ + current_contribution: expectedContribution, + next_cycle_contribution: expectedContribution, + }); + } + + for (const futureProduct of [futureA, futureB]) { + const customerProduct = getPooledSourceCustomerProduct({ + state: after, + productId: futureProduct.id, + entityId: entities[0]!.id, + }); + expect(customerProduct.status).toBe(CusProductStatus.Scheduled); + expect( + after.contributions.some( + (contribution) => + contribution.source_customer_product_id === customerProduct.id, + ), + ).toBe(false); + } + + const preservedPrepaidCustomerProduct = getCustomerLevelCustomerProduct({ + state: after, + productId: prepaidCredits.id, + }); + expect(preservedPrepaidCustomerProduct.id).toBe(prepaidCustomerProduct.id); + expect(preservedPrepaidCustomerProduct.status).toBe( + CusProductStatus.Active, + ); + expect( + preservedPrepaidCustomerProduct.customer_entitlements.find( + (customerEntitlement) => + customerEntitlement.feature_id === TestFeature.Words, + )?.balance, + ).toBe(prepaidEntitlement?.balance); + + const customer = await autumnV2_2.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + granted: 500, + includedGrant: 500, + remaining: 450, + usage: USAGE, + planId: null, + breakdownCount: 1, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("pooled create-schedule: customer replacement preserves entity pools")}`, + async () => { + const customerId = "pooled-create-schedule-customer"; + const entityPool = pooledMessagesPlan({ + id: "pooled-schedule-entity-pool", + grant: 100, + group: "entity-pool", + }); + const currentCustomerPlan = products.base({ + id: "pooled-schedule-customer-current", + group: "customer-plan", + items: [items.monthlyWords({ includedUsage: 25 })], + }); + const incomingCustomerPlan = products.base({ + id: "pooled-schedule-customer-incoming", + group: "customer-plan", + items: [items.monthlyWords({ includedUsage: 50 })], + }); + + const { entities, autumnV2_2, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.entities({ count: 3, featureId: TestFeature.Users }), + s.products({ + list: [entityPool, currentCustomerPlan, incomingCustomerPlan], + }), + ], + actions: [ + s.billing.attach({ productId: entityPool.id, entityIndex: 0 }), + s.billing.attach({ productId: entityPool.id, entityIndex: 1 }), + s.billing.attach({ productId: currentCustomerPlan.id }), + s.track({ + featureId: TestFeature.Messages, + value: USAGE, + entityIndex: 2, + timeout: 2000, + }), + ], + }); + + const before = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: 150, + adjustment: 0, + granted: 200, + ...MONTHLY_LAZY_POOL, + }, + contributions: { + count: 2, + currentContribution: 100, + nextCycleContribution: 100, + }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + const contributionIds = before.contributions.map( + (contribution) => contribution.id, + ); + const entityCustomerProductIds = entities.slice(0, 2).map( + (entity) => + getPooledSourceCustomerProduct({ + state: before, + productId: entityPool.id, + entityId: entity.id, + }).id, + ); + + await autumnV2_2.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [{ plan_id: incomingCustomerPlan.id }], + }, + ], + }); + + const after = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: 150, + adjustment: 0, + granted: 200, + ...MONTHLY_LAZY_POOL, + }, + contributions: { + count: 2, + currentContribution: 100, + nextCycleContribution: 100, + }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + expect( + after.contributions.map((contribution) => contribution.id).sort(), + ).toEqual(contributionIds.sort()); + for (const customerProductId of entityCustomerProductIds) { + expect( + after.sourceCustomerProducts.find( + (customerProduct) => customerProduct.id === customerProductId, + ), + ).toMatchObject({ status: CusProductStatus.Active }); + } + }, +); diff --git a/server/tests/integration/billing/pooled-balances/cron/revert-trial-pooled-balance.test.ts b/server/tests/integration/billing/pooled-balances/cron/revert-trial-pooled-balance.test.ts new file mode 100644 index 0000000000..01c5ae6f11 --- /dev/null +++ b/server/tests/integration/billing/pooled-balances/cron/revert-trial-pooled-balance.test.ts @@ -0,0 +1,219 @@ +/** TDD contract: cron expiry removes a pooled trial source and restores its paused source. */ +// The monthly pool is reused, and its contribution graph changes without duplication. + +import { expect, test } from "bun:test"; +import { + ALL_STATUSES, + type AttachParamsV1Input, + CusProductStatus, + customerEntitlements, + customerProducts, + EntInterval, + FreeTrialDuration, + PooledBalanceResetMode, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { runProductCron } from "@/cron/productCron/runProductCron"; +import { db } from "@/db/initDrizzle"; +import { logger } from "@/external/logtail/logtailUtils"; +import { CusService } from "@/internal/customers/CusService"; +import { expectPooledBalanceCorrect } from "../utils/expectPooledBalanceCorrect"; +import { getPooledSourceCustomerProduct } from "../utils/getPooledBalanceDbState"; + +const PRO_GRANT = 100; +const ENTERPRISE_GRANT = 300; +const MONTHLY_POOL_LIFECYCLE = { + interval: EntInterval.Month, + nextResetAt: "present", + resetCycleAnchor: "present", + resetMode: PooledBalanceResetMode.Subscription, + stripeSubscriptionId: "stripe_subscription", +} as const; + +test( + chalk.yellowBright( + "pooled product cron: enterprise trial expiry restores the paused plan contribution", + ), + async () => { + const customerId = "pooled-trial-revert-cron"; + const pro = products.pro({ + id: "pooled-trial-revert-pro", + items: [ + { + ...items.monthlyMessages({ includedUsage: PRO_GRANT }), + pooled: true, + }, + ], + }); + const enterprise = products.premium({ + id: "pooled-trial-revert-enterprise", + items: [ + { + ...items.monthlyMessages({ includedUsage: ENTERPRISE_GRANT }), + pooled: true, + }, + ], + }); + const { autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.entities({ count: 1, featureId: TestFeature.Users }), + s.products({ list: [pro, enterprise] }), + ], + actions: [s.billing.attach({ productId: pro.id, entityIndex: 0 })], + }); + + const proState = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: PRO_GRANT, + adjustment: 0, + granted: PRO_GRANT, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { + count: 1, + currentContribution: PRO_GRANT, + nextCycleContribution: PRO_GRANT, + }, + sources: { count: 1, balance: 0, adjustment: 0 }, + }); + const proCustomerProduct = getPooledSourceCustomerProduct({ + state: proState, + productId: pro.id, + entityId: entities[0].id, + }); + const proContribution = proState.contributions[0]; + if (!proContribution) throw new Error("Expected the Pro contribution"); + + await autumnV2_2.billing.attach({ + customer_id: customerId, + entity_id: entities[0].id, + plan_id: enterprise.id, + redirect_mode: "if_required", + customize: { + free_trial: { + duration_length: 14, + duration_type: FreeTrialDuration.Day, + card_required: false, + on_end: "revert", + }, + }, + }); + + const trialState = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: ENTERPRISE_GRANT, + adjustment: 0, + granted: ENTERPRISE_GRANT, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { + count: 1, + currentContribution: ENTERPRISE_GRANT, + nextCycleContribution: ENTERPRISE_GRANT, + excludedSourceCustomerProductIds: [proCustomerProduct.id], + }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + const enterpriseCustomerProduct = getPooledSourceCustomerProduct({ + state: trialState, + productId: enterprise.id, + entityId: entities[0].id, + }); + const enterpriseContribution = trialState.contributions[0]; + if (!enterpriseContribution) { + throw new Error("Expected the Enterprise contribution"); + } + + const beforeCron = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + inStatuses: ALL_STATUSES, + }); + expect( + beforeCron.customer_products.find( + (customerProduct) => customerProduct.id === proCustomerProduct.id, + )?.status, + ).toBe(CusProductStatus.Paused); + expect( + beforeCron.customer_products.find( + (customerProduct) => + customerProduct.id === enterpriseCustomerProduct.id, + )?.status, + ).toBe(CusProductStatus.Active); + + await db + .update(customerProducts) + .set({ trial_ends_at: Date.now() - 60_000 }) + .where(eq(customerProducts.id, enterpriseCustomerProduct.id)); + + await runProductCron({ ctx: { db, logger } }); + + const afterCron = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + inStatuses: ALL_STATUSES, + }); + expect( + afterCron.customer_products.find( + (customerProduct) => customerProduct.id === proCustomerProduct.id, + )?.status, + ).toBe(CusProductStatus.Active); + expect( + afterCron.customer_products.find( + (customerProduct) => + customerProduct.id === enterpriseCustomerProduct.id, + )?.status, + ).toBe(CusProductStatus.Expired); + + const revertedState = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: PRO_GRANT, + adjustment: 0, + granted: PRO_GRANT, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { + count: 1, + currentContribution: PRO_GRANT, + nextCycleContribution: PRO_GRANT, + excludedSourceCustomerProductIds: [enterpriseCustomerProduct.id], + }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + expect(revertedState.contributions[0]?.source_customer_entitlement_id).toBe( + proContribution.source_customer_entitlement_id, + ); + + const [restoredSource, expiredTrialSource] = await Promise.all([ + ctx.db.query.customerEntitlements.findFirst({ + where: eq( + customerEntitlements.id, + proContribution.source_customer_entitlement_id, + ), + }), + ctx.db.query.customerEntitlements.findFirst({ + where: eq( + customerEntitlements.id, + enterpriseContribution.source_customer_entitlement_id, + ), + }), + ]); + expect(restoredSource?.pooled_contribution_id).toBe( + revertedState.contributions[0]?.id, + ); + expect(expiredTrialSource?.pooled_contribution_id).toBeNull(); + }, +); diff --git a/server/tests/integration/billing/pooled-balances/customer-level/pooled-balance-customer-level-attach.test.ts b/server/tests/integration/billing/pooled-balances/customer-level/pooled-balance-customer-level-attach.test.ts new file mode 100644 index 0000000000..6b9d4ecf9e --- /dev/null +++ b/server/tests/integration/billing/pooled-balances/customer-level/pooled-balance-customer-level-attach.test.ts @@ -0,0 +1,262 @@ +/** + * TDD contract for pooled balances on CUSTOMER-LEVEL plans. + * + * A pooled item attached without an entity must behave exactly as it does when + * attached to an entity — it contributes to the shared pool rather than being + * silently dropped. + * + * Contract under test: + * New behaviors: + * - Attaching a plan with a pooled item at the customer level creates the + * pooled balance graph (pool row + synthetic customer entitlement + + * contribution), same as an entity-scoped attach. + * - A customer-level source and an entity-scoped source with matching pool + * identity coalesce into ONE pool; `granted` is the sum. + * - Usage tracked at the customer level and on an entity draw the same pool. + * - Replacing the customer-level plan deletes its contribution and + * decrements `granted` — the entity-scoped contribution survives. + * Side effects: + * - `pooled_balance_contributions.source_customer_product_id` points at a + * customer-level customer product (`entity_id` null). + * - The customer-level source customer entitlement is normalized to 0. + * + * Pre-impl red: `applyIncoming/OutgoingPooledBalanceSources` bail out unless the + * source customer product is entity-scoped, so no pool row is created and the + * feature disappears from the customer's balances entirely (pooled sources are + * hidden from the public balance list). + * Post-impl green: customer-level sources participate in the pool. + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + type ApiEntityV2, + type AttachParamsV1Input, + type CheckResponseV3, + EntInterval, + PooledBalanceResetMode, +} from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { expectPooledBalanceCorrect } from "../utils/expectPooledBalanceCorrect.js"; + +const CUSTOMER_GRANT = 100; +const ENTITY_GRANT = 50; +const PRIVATE_GRANT = 25; +const CUSTOMER_USAGE = 12; +const ENTITY_USAGE = 8; +const TOTAL_USAGE = CUSTOMER_USAGE + ENTITY_USAGE; + +// Both plans are free + monthly, so they share a lazy pool identity (no Stripe +// subscription, anchored on the customer) and must coalesce. +const LAZY_POOL_LIFECYCLE = { + interval: EntInterval.Month, + nextResetAt: "present", + resetCycleAnchor: "present", + resetMode: PooledBalanceResetMode.Lazy, + stripeSubscriptionId: null, +} as const; + +test( + chalk.yellowBright( + "pooled customer-level: a customer-scoped plan contributes to the pool like an entity", + ), + async () => { + const customerId = "pooled-customer-level-attach"; + const customerGroup = "pooled-customer-level-group"; + + const customerPlan = products.base({ + id: "pooled-customer-level-plan", + group: customerGroup, + items: [ + { + ...items.monthlyMessages({ includedUsage: CUSTOMER_GRANT }), + pooled: true, + }, + ], + }); + const entityPlan = products.base({ + id: "pooled-entity-level-plan", + group: "pooled-entity-level-group", + items: [ + { + ...items.monthlyMessages({ includedUsage: ENTITY_GRANT }), + pooled: true, + }, + ], + }); + // Same group as customerPlan → replaces it, and carries no pooled item. + const privatePlan = products.base({ + id: "private-customer-level-plan", + group: customerGroup, + items: [items.monthlyMessages({ includedUsage: PRIVATE_GRANT })], + }); + + const { autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.entities({ count: 2, featureId: TestFeature.Users }), + s.products({ list: [customerPlan, entityPlan, privatePlan] }), + ], + actions: [s.billing.attach({ productId: customerPlan.id })], + }); + + // ── Contract 1: customer-level attach creates the pool ────────────── + // Pre-fix: zero pool rows; Messages absent from the customer entirely. + const afterCustomerAttach = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: CUSTOMER_GRANT, + adjustment: 0, + granted: CUSTOMER_GRANT, + ...LAZY_POOL_LIFECYCLE, + }, + contributions: { + count: 1, + currentContribution: CUSTOMER_GRANT, + nextCycleContribution: CUSTOMER_GRANT, + }, + sources: { count: 1, balance: 0, adjustment: 0 }, + }); + const pooledCustomerEntitlement = + afterCustomerAttach.poolCustomerEntitlements[0]; + + // The contributing source is customer-scoped, not entity-scoped. + const customerLevelCustomerProduct = + afterCustomerAttach.sourceCustomerProducts.find( + (customerProduct) => customerProduct.product_id === customerPlan.id, + ); + if (!customerLevelCustomerProduct) { + throw new Error(`Customer-level '${customerPlan.id}' was not attached.`); + } + expect(customerLevelCustomerProduct.entity_id).toBeNull(); + expect(customerLevelCustomerProduct.internal_entity_id).toBeNull(); + expect( + afterCustomerAttach.contributions[0].source_customer_product_id, + ).toBe(customerLevelCustomerProduct.id); + + const customerAfterAttach = await autumnV2_2.customers.get( + customerId, + { skip_cache: "true" }, + ); + expectBalanceCorrect({ + customer: customerAfterAttach, + featureId: TestFeature.Messages, + granted: CUSTOMER_GRANT, + includedGrant: CUSTOMER_GRANT, + remaining: CUSTOMER_GRANT, + usage: 0, + planId: null, + breakdownCount: 1, + breakdownId: pooledCustomerEntitlement.id, + }); + + // ── Contract 2: an entity source joins the SAME pool ──────────────── + await autumnV2_2.billing.attach({ + customer_id: customerId, + entity_id: entities[0].id, + plan_id: entityPlan.id, + plan_schedule: "immediate", + }); + + await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: CUSTOMER_GRANT + ENTITY_GRANT, + adjustment: 0, + granted: CUSTOMER_GRANT + ENTITY_GRANT, + ...LAZY_POOL_LIFECYCLE, + }, + contributions: { count: 2 }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + + // Visible from an entity that holds no pooled plan of its own. + const entityWithoutPlan = await autumnV2_2.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: entityWithoutPlan, + featureId: TestFeature.Messages, + granted: CUSTOMER_GRANT + ENTITY_GRANT, + includedGrant: CUSTOMER_GRANT + ENTITY_GRANT, + remaining: CUSTOMER_GRANT + ENTITY_GRANT, + usage: 0, + planId: null, + breakdownCount: 1, + breakdownId: pooledCustomerEntitlement.id, + }); + + // ── Contract 3: customer-level and entity usage draw one pool ─────── + await autumnV2_2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: CUSTOMER_USAGE, + }); + await autumnV2_2.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: ENTITY_USAGE, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: CUSTOMER_GRANT + ENTITY_GRANT - TOTAL_USAGE, + adjustment: 0, + granted: CUSTOMER_GRANT + ENTITY_GRANT, + ...LAZY_POOL_LIFECYCLE, + }, + contributions: { count: 2 }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + + const check = await autumnV2_2.check({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + }); + expect(check.allowed).toBe(true); + expect(check.balance).toMatchObject({ + granted: CUSTOMER_GRANT + ENTITY_GRANT, + remaining: CUSTOMER_GRANT + ENTITY_GRANT - TOTAL_USAGE, + usage: TOTAL_USAGE, + }); + + // ── Contract 4: replacing the customer-level plan removes its share ── + await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: privatePlan.id, + plan_schedule: "immediate", + }); + + await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: ENTITY_GRANT - TOTAL_USAGE, + adjustment: 0, + granted: ENTITY_GRANT, + ...LAZY_POOL_LIFECYCLE, + }, + contributions: { + count: 1, + currentContribution: ENTITY_GRANT, + nextCycleContribution: ENTITY_GRANT, + excludedSourceCustomerProductIds: [customerLevelCustomerProduct.id], + }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + }, +); diff --git a/server/tests/integration/billing/pooled-balances/pooled-balance-private-to-pooled-transition.test.ts b/server/tests/integration/billing/pooled-balances/pooled-balance-private-to-pooled-transition.test.ts new file mode 100644 index 0000000000..e89e39365f --- /dev/null +++ b/server/tests/integration/billing/pooled-balances/pooled-balance-private-to-pooled-transition.test.ts @@ -0,0 +1,130 @@ +// Contract: an immediate regular-to-pooled entity plan switch carries usage into the synthetic pool. +// A 200-credit pooled grant with 40 carried usage starts at 160 before its source is normalized. + +import { expect, test } from "bun:test"; +import { + type ApiEntityV2, + type AttachParamsV1Input, + type CheckResponseV3, + EntInterval, + PooledBalanceResetMode, +} from "@autumn/shared"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js"; +import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { expectPooledBalanceCorrect } from "./utils/expectPooledBalanceCorrect.js"; + +const OLD_GRANT = 100; +const POOLED_GRANT = 200; +const CARRIED_USAGE = 40; + +test.concurrent( + `${chalk.yellowBright("pooled transition: regular entity credits become pooled without resetting usage")}`, + async () => { + const regularPlan = products.base({ + id: "regular-credits-before-pool", + items: [items.monthlyCredits({ includedUsage: OLD_GRANT })], + }); + const pooledPlan = products.base({ + id: "pooled-credits-after-regular", + items: [ + { + ...items.monthlyCredits({ includedUsage: POOLED_GRANT }), + pooled: true, + }, + ], + }); + const { autumnV2_2, ctx, customerId, entities } = await initScenario({ + customerId: "pooled-transition-private-to-pooled", + setup: [ + s.customer({ testClock: false }), + s.entities({ count: 2, featureId: TestFeature.Users }), + s.products({ list: [regularPlan, pooledPlan] }), + ], + actions: [ + s.billing.attach({ productId: regularPlan.id, entityIndex: 0 }), + s.track({ + featureId: TestFeature.Credits, + value: CARRIED_USAGE, + entityIndex: 0, + timeout: 2000, + }), + ], + }); + + await autumnV2_2.billing.attach({ + customer_id: customerId, + entity_id: entities[0].id, + plan_id: pooledPlan.id, + plan_schedule: "immediate", + carry_over_usages: { enabled: true }, + }); + + const state = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: POOLED_GRANT - CARRIED_USAGE, + adjustment: 0, + granted: POOLED_GRANT, + interval: EntInterval.Month, + nextResetAt: "present", + resetCycleAnchor: "present", + resetMode: PooledBalanceResetMode.Lazy, + stripeSubscriptionId: null, + }, + contributions: { + count: 1, + currentContribution: POOLED_GRANT, + nextCycleContribution: POOLED_GRANT, + }, + sources: { count: 1, balance: 0, adjustment: 0 }, + }); + const pooledCustomerEntitlement = state.poolCustomerEntitlements[0]; + + const entity = await autumnV2_2.entities.get( + customerId, + entities[0].id, + ); + await expectCustomerProducts({ + customer: entity, + active: [pooledPlan.id], + notPresent: [regularPlan.id], + }); + expectBalanceCorrect({ + customer: entity, + featureId: TestFeature.Credits, + granted: POOLED_GRANT, + includedGrant: POOLED_GRANT, + remaining: POOLED_GRANT - CARRIED_USAGE, + usage: CARRIED_USAGE, + planId: null, + breakdownCount: 1, + breakdownId: pooledCustomerEntitlement.id, + }); + + const check = await autumnV2_2.check({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Credits, + }); + expect(check.allowed).toBe(true); + expect(check.balance).toMatchObject({ + granted: POOLED_GRANT, + remaining: POOLED_GRANT - CARRIED_USAGE, + usage: CARRIED_USAGE, + }); + + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); diff --git a/server/tests/integration/billing/pooled-balances/sync/sync-pooled-balance.test.ts b/server/tests/integration/billing/pooled-balances/sync/sync-pooled-balance.test.ts new file mode 100644 index 0000000000..e72972f406 --- /dev/null +++ b/server/tests/integration/billing/pooled-balances/sync/sync-pooled-balance.test.ts @@ -0,0 +1,218 @@ +/** TDD contract: sync_v2 restores a pooled source and expire_previous replaces it. */ +// The pool stays unique while outgoing contribution links are removed and incoming links are inserted. + +import { expect, test } from "bun:test"; +import { + customerEntitlements, + EntInterval, + PooledBalanceResetMode, + type SyncParamsV1, + type UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { expectPooledBalanceCorrect } from "../utils/expectPooledBalanceCorrect"; +import { getPooledSourceCustomerProduct } from "../utils/getPooledBalanceDbState"; + +const GRANT = 100; +const MONTHLY_POOL_LIFECYCLE = { + interval: EntInterval.Month, + nextResetAt: "present", + resetCycleAnchor: "present", + resetMode: PooledBalanceResetMode.Subscription, + stripeSubscriptionId: "stripe_subscription", +} as const; + +test( + chalk.yellowBright( + "pooled sync: sync-back inserts a source and expire_previous replaces it", + ), + async () => { + const customerId = "pooled-sync-back-replacement"; + const plan = products.pro({ + id: "pooled-sync-back-plan", + items: [ + { + ...items.monthlyMessages({ includedUsage: GRANT }), + pooled: true, + }, + ], + }); + const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.entities({ count: 1, featureId: TestFeature.Users }), + s.products({ list: [plan] }), + ], + actions: [s.billing.attach({ productId: plan.id, entityIndex: 0 })], + }); + + const initialState = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: GRANT, + adjustment: 0, + granted: GRANT, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { + count: 1, + currentContribution: GRANT, + nextCycleContribution: GRANT, + }, + sources: { count: 1, balance: 0, adjustment: 0 }, + }); + const initialSource = getPooledSourceCustomerProduct({ + state: initialState, + productId: plan.id, + entityId: entities[0].id, + }); + const initialContribution = initialState.contributions[0]; + if (!initialContribution) { + throw new Error("Expected the initial pooled contribution"); + } + const stripeSubscriptionId = initialSource.subscription_ids?.[0]; + if (!stripeSubscriptionId) { + throw new Error("Expected the pooled source to have a subscription"); + } + + await autumnV2_2.subscriptions.update({ + customer_id: customerId, + customer_product_id: initialSource.id, + entity_id: entities[0].id, + cancel_action: "cancel_immediately", + no_billing_changes: true, + }); + + await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: 0, + adjustment: 0, + granted: 0, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { + count: 0, + excludedSourceCustomerProductIds: [initialSource.id], + }, + sources: { count: 1, balance: 0, adjustment: 0 }, + }); + const canceledSource = await ctx.db.query.customerEntitlements.findFirst({ + where: eq( + customerEntitlements.id, + initialContribution.source_customer_entitlement_id, + ), + }); + expect(canceledSource?.pooled_contribution_id).toBeNull(); + + const syncParams = { + customer_id: customerId, + stripe_subscription_id: stripeSubscriptionId, + phases: [ + { + starts_at: "now", + plans: [ + { + plan_id: plan.id, + entity_id: entities[0].id, + }, + ], + }, + ], + } satisfies SyncParamsV1; + await autumnV1.post("/billing.sync_v2", syncParams); + + const syncedState = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: GRANT, + adjustment: 0, + granted: GRANT, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { + count: 1, + currentContribution: GRANT, + nextCycleContribution: GRANT, + excludedSourceCustomerProductIds: [initialSource.id], + }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + const syncedSourceId = + syncedState.contributions[0]?.source_customer_product_id; + const syncedSourceEntitlementId = + syncedState.contributions[0]?.source_customer_entitlement_id; + expect(syncedSourceId).toBeDefined(); + expect(syncedSourceEntitlementId).toBeDefined(); + expect(syncedSourceId).not.toBe(initialSource.id); + const syncedSourceEntitlement = + await ctx.db.query.customerEntitlements.findFirst({ + where: eq(customerEntitlements.id, syncedSourceEntitlementId!), + }); + expect(syncedSourceEntitlement?.pooled_contribution_id).toBe( + syncedState.contributions[0]?.id, + ); + + await autumnV1.post("/billing.sync_v2", { + ...syncParams, + phases: [ + { + starts_at: "now", + plans: [ + { + plan_id: plan.id, + entity_id: entities[0].id, + expire_previous: true, + }, + ], + }, + ], + } satisfies SyncParamsV1); + + const replacedState = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: GRANT, + adjustment: 0, + granted: GRANT, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { + count: 1, + currentContribution: GRANT, + nextCycleContribution: GRANT, + excludedSourceCustomerProductIds: [initialSource.id, syncedSourceId!], + }, + sources: { count: 3, balance: 0, adjustment: 0 }, + }); + const replacementSourceId = + replacedState.contributions[0]?.source_customer_product_id; + const replacementSourceEntitlementId = + replacedState.contributions[0]?.source_customer_entitlement_id; + expect(replacementSourceId).toBeDefined(); + expect(replacementSourceEntitlementId).toBeDefined(); + expect(replacementSourceId).not.toBe(syncedSourceId); + const [expiredSyncedSource, replacementSource] = await Promise.all([ + ctx.db.query.customerEntitlements.findFirst({ + where: eq(customerEntitlements.id, syncedSourceEntitlementId!), + }), + ctx.db.query.customerEntitlements.findFirst({ + where: eq(customerEntitlements.id, replacementSourceEntitlementId!), + }), + ]); + expect(expiredSyncedSource?.pooled_contribution_id).toBeNull(); + expect(replacementSource?.pooled_contribution_id).toBe( + replacedState.contributions[0]?.id, + ); + }, +); diff --git a/server/tests/integration/billing/pooled-balances/update/cancel-pooled-balance.test.ts b/server/tests/integration/billing/pooled-balances/update/cancel-pooled-balance.test.ts new file mode 100644 index 0000000000..67e2a76a00 --- /dev/null +++ b/server/tests/integration/billing/pooled-balances/update/cancel-pooled-balance.test.ts @@ -0,0 +1,188 @@ +/** Pooled cancellation removes a source only when cancellation becomes effective. */ + +import { test } from "bun:test"; +import { + type ApiCustomerV5, + type ApiEntityV2, + EntInterval, + PooledBalanceResetMode, + type UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect/index.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { expectPooledBalanceCorrect } from "../utils/expectPooledBalanceCorrect.js"; +import { expectPooledSourceContributionsHydrated } from "../utils/expectPooledSourceContributionsHydrated"; +import { getPooledSourceCustomerProduct } from "../utils/getPooledBalanceDbState.js"; +import { waitForPooledBalanceCorrect } from "../utils/waitForPooledBalanceCorrect.js"; + +const CONTRIBUTION = 100; +const USAGE = 50; +const MONTHLY_POOL_LIFECYCLE = { + interval: EntInterval.Month, + nextResetAt: "present", + resetCycleAnchor: "present", + resetMode: PooledBalanceResetMode.Subscription, + stripeSubscriptionId: "stripe_subscription", +} as const; + +const runCancelCase = async ({ + cancelAction, +}: { + cancelAction: "cancel_immediately" | "cancel_end_of_cycle"; +}) => { + const customerId = `pooled-${cancelAction}`; + const pooledPlan = products.pro({ + id: `pooled-${cancelAction}-plan`, + items: [ + { + ...items.monthlyMessages({ includedUsage: CONTRIBUTION }), + pooled: true, + }, + ], + }); + const { entities, autumnV2_2, ctx, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.entities({ count: 3, featureId: TestFeature.Users }), + s.products({ list: [pooledPlan] }), + ], + actions: [ + s.billing.attach({ productId: pooledPlan.id, entityIndex: 0 }), + s.billing.attach({ productId: pooledPlan.id, entityIndex: 1 }), + s.track({ + featureId: TestFeature.Messages, + value: USAGE, + entityIndex: 2, + timeout: 2000, + }), + ], + }); + + const beforeCancel = await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: CONTRIBUTION * 2 - USAGE, + adjustment: 0, + granted: CONTRIBUTION * 2, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { + count: 2, + currentContribution: CONTRIBUTION, + nextCycleContribution: CONTRIBUTION, + }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + const outgoingCustomerProduct = getPooledSourceCustomerProduct({ + state: beforeCancel, + productId: pooledPlan.id, + entityId: entities[0].id, + }); + await expectPooledSourceContributionsHydrated({ + ctx, + customerId, + contributions: beforeCancel.contributions, + }); + + await autumnV2_2.subscriptions.update({ + customer_id: customerId, + customer_product_id: outgoingCustomerProduct.id, + entity_id: entities[0].id, + cancel_action: cancelAction, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + + const entityAfterRequest = await autumnV2_2.entities.get( + customerId, + entities[0].id, + { skip_cache: "true" }, + ); + await expectCustomerProducts({ + customer: entityAfterRequest, + ...(cancelAction === "cancel_immediately" + ? { notPresent: [pooledPlan.id] } + : { canceling: [pooledPlan.id] }), + }); + + if (cancelAction === "cancel_end_of_cycle") { + await expectPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: CONTRIBUTION * 2 - USAGE, + adjustment: 0, + granted: CONTRIBUTION * 2, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { count: 2 }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + + if (!testClockId) throw new Error("Expected a Stripe test clock"); + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId, + currentEpochMs: advancedTo, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + } + + const usageAfterTransition = + cancelAction === "cancel_immediately" ? USAGE : 0; + await waitForPooledBalanceCorrect({ + db: ctx.db, + customerId, + pool: { + balance: CONTRIBUTION - usageAfterTransition, + adjustment: 0, + granted: CONTRIBUTION, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { + count: 1, + currentContribution: CONTRIBUTION, + nextCycleContribution: CONTRIBUTION, + excludedSourceCustomerProductIds: [outgoingCustomerProduct.id], + }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + + const customerAfterTransition = await autumnV2_2.customers.get( + customerId, + { skip_cache: "true" }, + ); + expectBalanceCorrect({ + customer: customerAfterTransition, + featureId: TestFeature.Messages, + granted: CONTRIBUTION, + includedGrant: CONTRIBUTION, + remaining: CONTRIBUTION - usageAfterTransition, + usage: usageAfterTransition, + planId: null, + breakdownCount: 1, + }); +}; + +test.concurrent( + `${chalk.yellowBright("pooled update: cancel immediately removes the source contribution")}`, + async () => { + await runCancelCase({ cancelAction: "cancel_immediately" }); + }, +); + +test.concurrent( + `${chalk.yellowBright("pooled update: cancel end of cycle removes the source only at transition")}`, + async () => { + await runCancelCase({ cancelAction: "cancel_end_of_cycle" }); + }, +); diff --git a/server/tests/integration/billing/pooled-balances/update/customize-pooled-balance.test.ts b/server/tests/integration/billing/pooled-balances/update/customize-pooled-balance.test.ts new file mode 100644 index 0000000000..0921cfdf69 --- /dev/null +++ b/server/tests/integration/billing/pooled-balances/update/customize-pooled-balance.test.ts @@ -0,0 +1,153 @@ +/** PUT and PATCH customization preserve valid pools while rejecting pay-per-use pooled pricing. + * Unrelated edits preserve the pool; grant increases apply the delta; enabling pooling carries usage. */ + +import { test } from "bun:test"; +import { ErrCode, type UpdateSubscriptionV1ParamsInput } from "@autumn/shared"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import chalk from "chalk"; +import { + buildPooledCustomization, + buildRejectedPooledPricingCustomization, + expectPooledCustomizationResult, + expectPooledCustomizationUnchanged, + type PooledCustomizationCase, + setupPooledCustomizationScenario, +} from "../utils/pooledBalanceCustomizationTestUtils.js"; + +const runPooledCustomizationCase = async ({ + surface, + case: customizationCase, +}: { + surface: "put" | "patch"; + case: PooledCustomizationCase; +}) => { + const scenario = await setupPooledCustomizationScenario({ + customerId: `pooled-customize-${surface}-${customizationCase}`, + case: customizationCase, + }); + + await scenario.autumnV2_2.subscriptions.update( + { + customer_id: scenario.customerId, + customer_product_id: scenario.sourceCustomerProduct.id, + entity_id: scenario.entities[0].id, + customize: buildPooledCustomization({ + case: customizationCase, + surface, + }), + }, + ); + + await expectPooledCustomizationResult({ + scenario, + case: customizationCase, + surface, + }); +}; + +const runRejectedPooledPricingCase = async ({ + surface, +}: { + surface: "put" | "patch"; +}) => { + const scenario = await setupPooledCustomizationScenario({ + customerId: `pooled-customize-${surface}-reject-usage-based`, + case: "increase_grant", + }); + + await expectAutumnError({ + errCode: ErrCode.InvalidProductItem, + errMessage: "Pooled items cannot use usage-based pricing", + func: () => + scenario.autumnV2_2.subscriptions.update( + { + customer_id: scenario.customerId, + customer_product_id: scenario.sourceCustomerProduct.id, + entity_id: scenario.entities[0].id, + customize: buildRejectedPooledPricingCustomization({ + surface, + }), + }, + ), + }); + + await expectPooledCustomizationUnchanged({ scenario }); +}; + +test.concurrent( + `${chalk.yellowBright("pooled customize put: unrelated item preserves the pool")}`, + async () => { + await runPooledCustomizationCase({ + surface: "put", + case: "unrelated_item", + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("pooled customize put: pooled grant increase applies the delta")}`, + async () => { + await runPooledCustomizationCase({ + surface: "put", + case: "increase_grant", + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("pooled customize put: private to pooled carries usage")}`, + async () => { + await runPooledCustomizationCase({ + surface: "put", + case: "enable_pooling", + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("pooled customize patch: unrelated item preserves the pool")}`, + async () => { + await runPooledCustomizationCase({ + surface: "patch", + case: "unrelated_item", + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("pooled customize patch: pooled grant increase applies the delta")}`, + async () => { + await runPooledCustomizationCase({ + surface: "patch", + case: "increase_grant", + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("pooled customize patch: private to pooled carries usage")}`, + async () => { + await runPooledCustomizationCase({ + surface: "patch", + case: "enable_pooling", + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("pooled customize put: pay-per-use pooled pricing is rejected")}`, + async () => { + await runRejectedPooledPricingCase({ + surface: "put", + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("pooled customize patch: pay-per-use pooled pricing is rejected")}`, + async () => { + await runRejectedPooledPricingCase({ + surface: "patch", + }); + }, +); diff --git a/server/tests/integration/billing/pooled-balances/update/update-pooled-balance-with-rollover.test.ts b/server/tests/integration/billing/pooled-balances/update/update-pooled-balance-with-rollover.test.ts new file mode 100644 index 0000000000..c4732cbc4e --- /dev/null +++ b/server/tests/integration/billing/pooled-balances/update/update-pooled-balance-with-rollover.test.ts @@ -0,0 +1,39 @@ +/** After subscription renewal creates a capped pooled rollover, increasing the pooled grant preserves that rollover. + * The transition applies only the contribution delta and leaves the customer-level prepaid add-on unchanged. */ + +import { test } from "bun:test"; +import type { UpdateSubscriptionV1ParamsInput } from "@autumn/shared"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect/index.js"; +import chalk from "chalk"; +import { + expectPooledRolloverTransitionCorrect, + setupPooledRolloverTransitionScenario, + updatedPooledPlanItem, +} from "../utils/pooledBalanceRolloverTransitionTestUtils.js"; + +test.concurrent( + `${chalk.yellowBright("pooled rollover update: grant increase preserves rollover and prepaid add-on")}`, + async () => { + const scenario = await setupPooledRolloverTransitionScenario({ + customerId: "pooled-rollover-update-grant", + }); + + await scenario.autumnV2_2.subscriptions.update( + { + customer_id: scenario.customerId, + customer_product_id: scenario.sourceCustomerProduct.id, + entity_id: scenario.entities[0].id, + customize: { items: [updatedPooledPlanItem] }, + }, + ); + await expectStripeSubscriptionCorrect({ + ctx: scenario.ctx, + customerId: scenario.customerId, + }); + + await expectPooledRolloverTransitionCorrect({ + scenario, + expectedProductId: scenario.pro.id, + }); + }, +); diff --git a/server/tests/integration/billing/pooled-balances/utils/expectPooledBalanceCorrect.ts b/server/tests/integration/billing/pooled-balances/utils/expectPooledBalanceCorrect.ts index 77bdc58a40..1482cbe9e7 100644 --- a/server/tests/integration/billing/pooled-balances/utils/expectPooledBalanceCorrect.ts +++ b/server/tests/integration/billing/pooled-balances/utils/expectPooledBalanceCorrect.ts @@ -1,5 +1,5 @@ import { expect } from "bun:test"; -import type { EntInterval, PooledBalanceResetMode } from "@autumn/shared"; +import { type EntInterval, PooledBalanceResetMode } from "@autumn/shared"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { getPooledBalanceDbState } from "./getPooledBalanceDbState.js"; @@ -63,6 +63,9 @@ export const expectPooledBalanceCorrect = async ({ } else { expect(pooledCustomerEntitlement.next_reset_at).toBeNull(); } + if (pool.resetMode === PooledBalanceResetMode.Subscription) { + expect(pooledCustomerEntitlement.reset_by_invoice).toBe(true); + } if (pool.rollovers) { expect(pooledCustomerEntitlement.rollovers).toHaveLength( pool.rollovers.length, diff --git a/server/tests/integration/billing/pooled-balances/utils/expectPooledSourceContributionsHydrated.ts b/server/tests/integration/billing/pooled-balances/utils/expectPooledSourceContributionsHydrated.ts new file mode 100644 index 0000000000..d8a1e995ab --- /dev/null +++ b/server/tests/integration/billing/pooled-balances/utils/expectPooledSourceContributionsHydrated.ts @@ -0,0 +1,45 @@ +import { expect } from "bun:test"; +import { + type DbPooledBalanceContribution, + findCustomerEntitlementById, + findCustomerProductById, +} from "@autumn/shared"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import { CusService } from "@/internal/customers/CusService"; + +export const expectPooledSourceContributionsHydrated = async ({ + ctx, + customerId, + contributions, +}: { + ctx: TestContext; + customerId: string; + contributions: DbPooledBalanceContribution[]; +}) => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + withEntities: true, + withSubs: true, + }); + + for (const contribution of contributions) { + const customerProduct = findCustomerProductById({ + fullCustomer, + customerProductId: contribution.source_customer_product_id, + }); + expect(customerProduct).toBeDefined(); + const customerEntitlement = findCustomerEntitlementById({ + cusEnts: customerProduct?.customer_entitlements ?? [], + id: contribution.source_customer_entitlement_id, + errorOnNotFound: true, + }); + + expect(customerEntitlement.pooled_balance_contribution).toMatchObject({ + id: contribution.id, + pooled_balance_id: contribution.pooled_balance_id, + source_customer_product_id: customerProduct?.id, + source_customer_entitlement_id: customerEntitlement.id, + }); + } +}; diff --git a/server/tests/integration/billing/pooled-balances/utils/pooledBalanceCustomizationTestUtils.ts b/server/tests/integration/billing/pooled-balances/utils/pooledBalanceCustomizationTestUtils.ts new file mode 100644 index 0000000000..94c18419a4 --- /dev/null +++ b/server/tests/integration/billing/pooled-balances/utils/pooledBalanceCustomizationTestUtils.ts @@ -0,0 +1,346 @@ +import { expect } from "bun:test"; +import { + type ApiCustomerV5, + type ApiEntityV2, + CusProductStatus, + EntInterval, + PooledBalanceResetMode, + type UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import type { MigrationUpdatePlanCustomize } from "@autumn/shared/api/migrations/operations/customer/updatePlan/index.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import { expectPooledBalanceCorrect } from "./expectPooledBalanceCorrect.js"; +import { + getPooledBalanceDbState, + getPooledSourceCustomerProduct, +} from "./getPooledBalanceDbState.js"; + +export type PooledCustomizationCase = + | "unrelated_item" + | "increase_grant" + | "enable_pooling"; + +export type PooledCustomizationSurface = "put" | "patch" | "migration"; + +const INITIAL_GRANT = 100; +const UPDATED_GRANT = 200; +const INITIAL_WORDS_GRANT = 25; +const UPDATED_WORDS_GRANT = 75; +const USAGE = 50; + +const MONTHLY_POOL_LIFECYCLE = { + interval: EntInterval.Month, + nextResetAt: "present", + resetCycleAnchor: "present", + resetMode: PooledBalanceResetMode.Subscription, + stripeSubscriptionId: "stripe_subscription", +} as const; + +export const buildPooledCustomization = ({ + case: customizationCase, + surface, +}: { + case: PooledCustomizationCase; + surface: Exclude; +}): NonNullable => { + if (surface === "put") { + return { + items: [ + { + ...itemsV2.monthlyMessages({ + included: + customizationCase === "increase_grant" + ? UPDATED_GRANT + : INITIAL_GRANT, + }), + pooled: true, + }, + itemsV2.monthlyWords({ + included: + customizationCase === "unrelated_item" + ? UPDATED_WORDS_GRANT + : INITIAL_WORDS_GRANT, + }), + ], + }; + } + + return buildPooledPatchCustomization({ case: customizationCase }); +}; + +export const buildRejectedPooledPricingCustomization = ({ + surface, +}: { + surface: Exclude; +}): NonNullable => { + const pooledItem = { + ...itemsV2.consumableMessages(), + pooled: true, + }; + + if (surface === "put") { + return { + items: [ + pooledItem, + itemsV2.monthlyWords({ included: INITIAL_WORDS_GRANT }), + ], + }; + } + + return { + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [pooledItem], + }; +}; + +const buildPooledPatchCustomization = ({ + case: customizationCase, +}: { + case: PooledCustomizationCase; +}) => { + if (customizationCase === "unrelated_item") { + return { + update_items: [ + { + filter: { feature_id: TestFeature.Words }, + included: UPDATED_WORDS_GRANT, + }, + ], + }; + } + + if (customizationCase === "increase_grant") { + return { + update_items: [ + { + filter: { feature_id: TestFeature.Messages }, + included: UPDATED_GRANT, + }, + ], + }; + } + + return { + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [ + { + ...itemsV2.monthlyMessages({ included: INITIAL_GRANT }), + pooled: true, + }, + ], + }; +}; + +export const buildPooledMigrationCustomization = ({ + case: customizationCase, +}: { + case: PooledCustomizationCase; +}): MigrationUpdatePlanCustomize => + buildPooledPatchCustomization({ case: customizationCase }); + +export const setupPooledCustomizationScenario = async ({ + customerId, + case: customizationCase, +}: { + customerId: string; + case: PooledCustomizationCase; +}) => { + const initiallyPooled = customizationCase !== "enable_pooling"; + const plan = products.pro({ + id: `${customerId}-plan`, + items: [ + { + ...items.monthlyMessages({ includedUsage: INITIAL_GRANT }), + pooled: initiallyPooled, + }, + items.monthlyWords({ includedUsage: INITIAL_WORDS_GRANT }), + ], + }); + const scenario = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.entities({ count: 2, featureId: TestFeature.Users }), + s.products({ list: [plan] }), + ], + actions: [ + s.billing.attach({ productId: plan.id, entityIndex: 0 }), + s.track({ + featureId: TestFeature.Messages, + value: USAGE, + entityIndex: initiallyPooled ? 1 : 0, + timeout: 2000, + }), + ], + }); + + const state = initiallyPooled + ? await expectPooledBalanceCorrect({ + db: scenario.ctx.db, + customerId, + pool: { + balance: INITIAL_GRANT - USAGE, + adjustment: 0, + granted: INITIAL_GRANT, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { + count: 1, + currentContribution: INITIAL_GRANT, + nextCycleContribution: INITIAL_GRANT, + }, + sources: { count: 1, balance: 0, adjustment: 0 }, + }) + : await getPooledBalanceDbState({ db: scenario.ctx.db, customerId }); + + if (!initiallyPooled) { + expect(state.pools).toHaveLength(0); + expect(state.contributions).toHaveLength(0); + } + + const sourceCustomerProduct = getPooledSourceCustomerProduct({ + state, + productId: plan.id, + entityId: scenario.entities[0].id, + }); + const sourceContribution = state.contributions.find( + (contribution) => + contribution.source_customer_product_id === sourceCustomerProduct.id, + ); + + return { + ...scenario, + plan, + sourceCustomerProduct, + sourceContributionId: sourceContribution?.id, + }; +}; + +export const expectPooledCustomizationResult = async ({ + scenario, + case: customizationCase, + surface, +}: { + scenario: Awaited>; + case: PooledCustomizationCase; + surface: PooledCustomizationSurface; +}) => { + const expectedGrant = + customizationCase === "increase_grant" ? UPDATED_GRANT : INITIAL_GRANT; + const expectedSourceCount = + surface === "put" && customizationCase !== "enable_pooling" ? 2 : 1; + const state = await expectPooledBalanceCorrect({ + db: scenario.ctx.db, + customerId: scenario.customerId, + pool: { + balance: expectedGrant - USAGE, + adjustment: 0, + granted: expectedGrant, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { + count: 1, + currentContribution: expectedGrant, + nextCycleContribution: expectedGrant, + }, + sources: { count: expectedSourceCount, balance: 0, adjustment: 0 }, + }); + + const activeCustomerProduct = state.sourceCustomerProducts.find( + (customerProduct) => + customerProduct.product_id === scenario.plan.id && + customerProduct.entity_id === scenario.entities[0].id && + customerProduct.status === CusProductStatus.Active, + ); + expect(activeCustomerProduct).toBeDefined(); + expect(activeCustomerProduct?.id === scenario.sourceCustomerProduct.id).toBe( + surface !== "put", + ); + + const activeContribution = state.contributions.find( + (contribution) => + contribution.source_customer_product_id === activeCustomerProduct?.id, + ); + expect(activeContribution).toMatchObject({ + current_contribution: expectedGrant, + next_cycle_contribution: expectedGrant, + }); + if (customizationCase === "unrelated_item" && surface !== "put") { + expect(activeContribution?.id).toBe(scenario.sourceContributionId); + } + + const customer = await scenario.autumnV2_2.customers.get( + scenario.customerId, + { skip_cache: "true" }, + ); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + granted: expectedGrant, + includedGrant: expectedGrant, + remaining: expectedGrant - USAGE, + usage: USAGE, + planId: null, + breakdownCount: 1, + }); + + const entity = await scenario.autumnV2_2.entities.get( + scenario.customerId, + scenario.entities[0].id, + { skip_cache: "true" }, + ); + expectBalanceCorrect({ + customer: entity, + featureId: TestFeature.Words, + granted: + customizationCase === "unrelated_item" + ? UPDATED_WORDS_GRANT + : INITIAL_WORDS_GRANT, + remaining: + customizationCase === "unrelated_item" + ? UPDATED_WORDS_GRANT + : INITIAL_WORDS_GRANT, + usage: 0, + planId: scenario.plan.id, + breakdownCount: 1, + }); +}; + +export const expectPooledCustomizationUnchanged = async ({ + scenario, +}: { + scenario: Awaited>; +}) => { + const state = await expectPooledBalanceCorrect({ + db: scenario.ctx.db, + customerId: scenario.customerId, + pool: { + balance: INITIAL_GRANT - USAGE, + adjustment: 0, + granted: INITIAL_GRANT, + ...MONTHLY_POOL_LIFECYCLE, + }, + contributions: { + count: 1, + currentContribution: INITIAL_GRANT, + nextCycleContribution: INITIAL_GRANT, + }, + sources: { count: 1, balance: 0, adjustment: 0 }, + }); + + expect( + state.sourceCustomerProducts.find( + (customerProduct) => + customerProduct.id === scenario.sourceCustomerProduct.id, + )?.status, + ).toBe(CusProductStatus.Active); + if (!scenario.sourceContributionId) { + throw new Error("Expected the original pooled contribution"); + } + expect(state.contributions[0]?.id).toBe(scenario.sourceContributionId); +}; diff --git a/server/tests/integration/billing/pooled-balances/utils/pooledBalanceRolloverTransitionTestUtils.ts b/server/tests/integration/billing/pooled-balances/utils/pooledBalanceRolloverTransitionTestUtils.ts new file mode 100644 index 0000000000..b1a93e1c06 --- /dev/null +++ b/server/tests/integration/billing/pooled-balances/utils/pooledBalanceRolloverTransitionTestUtils.ts @@ -0,0 +1,214 @@ +import { expect } from "bun:test"; +import { + type ApiCustomerV5, + CusProductStatus, + EntInterval, + PooledBalanceResetMode, + ResetInterval, + RolloverExpiryDurationType, +} from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import { expectPooledBalanceCorrect } from "./expectPooledBalanceCorrect.js"; +import { getPooledSourceCustomerProduct } from "./getPooledBalanceDbState.js"; + +export const INITIAL_POOLED_GRANT = 100; +export const UPDATED_POOLED_GRANT = 200; +const PREPAID_GRANT = 100; +const USAGE = 50; +const ROLLOVER_GRANT = 50; +const PRODUCT_GROUP = "pooled-rollover-transition"; + +const rolloverConfig = { + max_percentage: 50, + length: 1, + duration: RolloverExpiryDurationType.Month, +} as const; + +export const updatedPooledPlanItem = { + feature_id: TestFeature.Messages, + included: UPDATED_POOLED_GRANT, + pooled: true, + reset: { interval: ResetInterval.Month }, + rollover: { + max_percentage: 50, + expiry_duration_type: RolloverExpiryDurationType.Month, + expiry_duration_length: 1, + }, +}; + +export const setupPooledRolloverTransitionScenario = async ({ + customerId, +}: { + customerId: string; +}) => { + const prepaidAddon = products.oneOffAddOn({ + id: `${customerId}-prepaid-addon`, + items: [items.oneOffMessages({ includedUsage: PREPAID_GRANT })], + }); + const pro = products.pro({ + id: `${customerId}-pro`, + group: PRODUCT_GROUP, + items: [ + { + ...items.monthlyMessagesWithRollover({ + includedUsage: INITIAL_POOLED_GRANT, + rolloverConfig, + }), + pooled: true, + }, + ], + }); + const enterprise = products.base({ + id: `${customerId}-enterprise`, + group: PRODUCT_GROUP, + items: [ + { + ...items.monthlyMessagesWithRollover({ + includedUsage: UPDATED_POOLED_GRANT, + rolloverConfig, + }), + pooled: true, + }, + items.monthlyPrice({ price: 50 }), + ], + }); + + const scenario = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.entities({ count: 2, featureId: TestFeature.Users }), + s.products({ list: [prepaidAddon, pro, enterprise] }), + ], + actions: [ + s.billing.attach({ productId: prepaidAddon.id }), + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + s.track({ + featureId: TestFeature.Messages, + value: USAGE, + entityIndex: 1, + timeout: 2_000, + }), + s.advanceToNextInvoice(), + ], + }); + + const state = await expectPooledBalanceCorrect({ + db: scenario.ctx.db, + customerId, + pool: { + balance: INITIAL_POOLED_GRANT, + adjustment: 0, + granted: INITIAL_POOLED_GRANT, + interval: EntInterval.Month, + nextResetAt: "present", + resetCycleAnchor: "present", + resetMode: PooledBalanceResetMode.Subscription, + stripeSubscriptionId: "stripe_subscription", + rollovers: [{ balance: ROLLOVER_GRANT, usage: 0 }], + }, + contributions: { + count: 1, + currentContribution: INITIAL_POOLED_GRANT, + nextCycleContribution: INITIAL_POOLED_GRANT, + }, + sources: { count: 1, balance: 0, adjustment: 0 }, + }); + const sourceCustomerProduct = getPooledSourceCustomerProduct({ + state, + productId: pro.id, + entityId: scenario.entities[0].id, + }); + const prepaidCustomerProduct = state.sourceCustomerProducts.find( + (customerProduct) => customerProduct.product_id === prepaidAddon.id, + ); + const prepaidCustomerEntitlement = + prepaidCustomerProduct?.customer_entitlements.find( + (customerEntitlement) => + customerEntitlement.feature_id === TestFeature.Messages, + ); + const rollover = state.poolCustomerEntitlements[0]?.rollovers[0]; + + expect(prepaidCustomerProduct).toBeDefined(); + expect(prepaidCustomerEntitlement?.balance).toBe(PREPAID_GRANT); + expect(rollover?.balance).toBe(ROLLOVER_GRANT); + + return { + ...scenario, + enterprise, + prepaidCustomerEntitlementId: prepaidCustomerEntitlement?.id, + prepaidCustomerProductId: prepaidCustomerProduct?.id, + pro, + rolloverId: rollover?.id, + sourceCustomerProduct, + }; +}; + +export const expectPooledRolloverTransitionCorrect = async ({ + scenario, + expectedProductId, +}: { + scenario: Awaited>; + expectedProductId: string; +}) => { + const state = await expectPooledBalanceCorrect({ + db: scenario.ctx.db, + customerId: scenario.customerId, + pool: { + balance: UPDATED_POOLED_GRANT, + adjustment: 0, + granted: UPDATED_POOLED_GRANT, + interval: EntInterval.Month, + nextResetAt: "present", + resetCycleAnchor: "present", + resetMode: PooledBalanceResetMode.Subscription, + stripeSubscriptionId: "stripe_subscription", + rollovers: [{ balance: ROLLOVER_GRANT, usage: 0 }], + }, + contributions: { + count: 1, + currentContribution: UPDATED_POOLED_GRANT, + nextCycleContribution: UPDATED_POOLED_GRANT, + excludedSourceCustomerProductIds: [scenario.sourceCustomerProduct.id], + }, + sources: { count: 2, balance: 0, adjustment: 0 }, + }); + + expect(state.poolCustomerEntitlements[0]?.rollovers[0]?.id).toBe( + scenario.rolloverId, + ); + const prepaidCustomerProduct = state.sourceCustomerProducts.find( + (customerProduct) => + customerProduct.id === scenario.prepaidCustomerProductId, + ); + const prepaidCustomerEntitlement = + prepaidCustomerProduct?.customer_entitlements.find( + (customerEntitlement) => + customerEntitlement.id === scenario.prepaidCustomerEntitlementId, + ); + expect(prepaidCustomerProduct?.status).toBe(CusProductStatus.Active); + expect(prepaidCustomerEntitlement?.balance).toBe(PREPAID_GRANT); + expect( + state.sourceCustomerProducts.some( + (customerProduct) => + customerProduct.product_id === expectedProductId && + customerProduct.status === CusProductStatus.Active, + ), + ).toBe(true); + + const customer = await scenario.autumnV2_2.customers.get( + scenario.customerId, + { skip_cache: "true" }, + ); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: UPDATED_POOLED_GRANT + ROLLOVER_GRANT + PREPAID_GRANT, + usage: 0, + rollovers: [{ balance: ROLLOVER_GRANT }], + }); +}; diff --git a/server/tests/integration/billing/pooled-balances/utils/waitForPooledBalanceCorrect.ts b/server/tests/integration/billing/pooled-balances/utils/waitForPooledBalanceCorrect.ts new file mode 100644 index 0000000000..755774b7e8 --- /dev/null +++ b/server/tests/integration/billing/pooled-balances/utils/waitForPooledBalanceCorrect.ts @@ -0,0 +1,29 @@ +import { timeout } from "@/utils/genUtils.js"; +import { expectPooledBalanceCorrect } from "./expectPooledBalanceCorrect.js"; + +type PooledBalanceExpectation = Parameters< + typeof expectPooledBalanceCorrect +>[0]; + +export const waitForPooledBalanceCorrect = async ({ + timeoutMs = 60_000, + pollIntervalMs = 1_000, + ...expectation +}: PooledBalanceExpectation & { + timeoutMs?: number; + pollIntervalMs?: number; +}) => { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + return await expectPooledBalanceCorrect(expectation); + } catch (error) { + lastError = error; + await timeout(pollIntervalMs); + } + } + + throw lastError ?? new Error("Timed out waiting for pooled balance state."); +}; diff --git a/server/tests/integration/billing/rollback/rollback.test.ts b/server/tests/integration/billing/rollback/rollback.test.ts new file mode 100644 index 0000000000..e7e966528a --- /dev/null +++ b/server/tests/integration/billing/rollback/rollback.test.ts @@ -0,0 +1,108 @@ +/** A committed Autumn plan rolls back without touching Stripe. */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV5, AttachParamsV1 } from "@autumn/shared"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { billingActions } from "@/internal/billing/v2/actions"; +import { CusService } from "@/internal/customers/CusService"; +import { getCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer"; +import { setCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullCustomer"; + +test.concurrent( + `${chalk.yellowBright("billing.rollback: restores Autumn without creating Stripe resources")}`, + async () => { + const customerId = "billing-rollback-paid-attach"; + const pro = products.pro({ + id: "rollback-pro", + items: [], + }); + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + setup: [ + s.deleteCustomer({ customerId }), + s.products({ + list: [pro], + prefix: customerId, + createInStripe: false, + }), + ], + actions: [], + }); + await autumnV1.customers.create({ + id: customerId, + name: customerId, + email: `${customerId}@example.com`, + currency: "usd", + internalOptions: { disable_defaults: true }, + }); + const attached = await billingActions.attach({ + ctx, + params: { + customer_id: customerId, + plan_id: pro.id, + redirect_mode: "if_required", + no_billing_changes: true, + } satisfies AttachParamsV1, + skipAutumnCheckout: true, + }); + if (!attached.billingPlan) + throw new Error("Expected an executed billing plan"); + const autumnBillingPlan = attached.billingPlan.autumn; + const attachedCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + await setCachedFullCustomer({ + ctx, + fullCustomer: attachedCustomer, + customerId, + fetchTimeMs: Date.now(), + source: "billing.rollback.test", + overwrite: true, + }); + expect( + ( + await getCachedFullCustomer({ + ctx, + customerId, + }) + )?.customer_products, + ).toHaveLength(1); + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + + const rollbackPlan = await billingActions.rollback({ + ctx, + autumnBillingPlan, + }); + + expect(rollbackPlan.deleteCustomerProducts).toEqual( + autumnBillingPlan.insertCustomerProducts, + ); + expect(await getCachedFullCustomer({ ctx, customerId })).toBeUndefined(); + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, notPresent: [pro.id] }); + expect( + ( + await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }) + ).customer_products, + ).toHaveLength(0); + + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts index 5d8aa48f01..0159e3f227 100644 --- a/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts @@ -14,7 +14,7 @@ import { TestFeature } from "@tests/setup/v2Features"; import { completeStripeCheckoutFormV2 } 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 { pollUntil, timeout } from "@tests/utils/genUtils"; import ctx from "@tests/utils/testInitUtils/createTestContext"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; @@ -199,11 +199,13 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync: links produ expect(checkoutSession.url).toContain("checkout.stripe.com"); await completeStripeCheckoutFormV2({ url: checkoutSession.url! }); - await timeout(12000); - const completedSession = await ctx.stripeCli.checkout.sessions.retrieve( - checkoutSession.id, - ); + const completedSession = await pollUntil({ + fetch: () => ctx.stripeCli.checkout.sessions.retrieve(checkoutSession.id), + until: (session) => Boolean(session.subscription), + timeoutMs: 30_000, + intervalMs: 1_000, + }); const subscriptionId = typeof completedSession.subscription === "string" ? completedSession.subscription @@ -214,7 +216,16 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync: links produ const stripeSubscription = await ctx.stripeCli.subscriptions.retrieve(subscriptionId); - const customer = await autumnV1.customers.get(customerId); + const customer = await pollUntil({ + fetch: () => + autumnV1.customers.get(customerId, { + skip_cache: "true", + }), + until: (candidate) => + candidate.features[TestFeature.Messages] !== undefined, + timeoutMs: 30_000, + intervalMs: 500, + }); await expectProductActive({ customer, productId: pro.id }); expectCustomerFeatureCorrect({ customer, diff --git a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-features.test.ts b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-features.test.ts index 8586a3ea34..21660bf94f 100644 --- a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-features.test.ts +++ b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-features.test.ts @@ -1,700 +1,324 @@ import { expect, test } from "bun:test"; -import { - type ApiCustomerV3, - EntInterval, - ProductItemInterval, -} from "@autumn/shared"; +import type { ApiCustomerV3 } from "@autumn/shared"; import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; import { TestFeature } from "@tests/setup/v2Features"; import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; -import { advanceTestClock } from "@tests/utils/stripeUtils"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem"; // ═══════════════════════════════════════════════════════════════════════════════ // PAID-TO-PAID: INCLUDED USAGE SHIFTS // ═══════════════════════════════════════════════════════════════════════════════ // 4.8 Shift included usage up (50 -> 200) -test.concurrent(`${chalk.yellowBright("p2p: shift included usage up")}`, async () => { - const consumableItem = items.consumableMessages({ includedUsage: 50 }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ id: "pro", items: [consumableItem, priceItem] }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "p2p-shift-inc-up", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [s.attach({ productId: "pro" })], - }); - - // Track usage that puts us in overage (80 used, 50 included = 30 overage) - const messagesUsage = 80; - await autumnV1.track( - { +test.concurrent( + `${chalk.yellowBright("p2p: shift included usage up")}`, + async () => { + const consumableItem = items.consumableMessages({ includedUsage: 50 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [consumableItem, priceItem], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "p2p-shift-inc-up", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: "pro" })], + }); + + // Track usage that puts us in overage (80 used, 50 included = 30 overage) + const messagesUsage = 80; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + // Shift included up to 200 - should cover existing usage + const newConsumableItem = items.consumableMessages({ includedUsage: 200 }); + + const updateParams = { customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsage, - }, - { timeout: 2000 }, - ); - - // Shift included up to 200 - should cover existing usage - const newConsumableItem = items.consumableMessages({ includedUsage: 200 }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [newConsumableItem, priceItem], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // No price change, just included usage shift - expect(preview.total).toBe(0); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Usage preserved, now within included - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: newConsumableItem.included_usage, - balance: newConsumableItem.included_usage - messagesUsage, // 200 - 80 = 120 - usage: messagesUsage, - }); - - // Verify invoice matches preview - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); + product_id: pro.id, + items: [newConsumableItem, priceItem], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // No price change, just included usage shift + expect(preview.total).toBe(0); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Usage preserved, now within included + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: newConsumableItem.included_usage, + balance: newConsumableItem.included_usage - messagesUsage, // 200 - 80 = 120 + usage: messagesUsage, + }); + + // Verify invoice matches preview + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); // 4.9 Shift included usage down (200 -> 50) into overage -test.concurrent(`${chalk.yellowBright("p2p: shift included usage down into overage")}`, async () => { - const consumableItem = items.consumableMessages({ includedUsage: 200 }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ id: "pro", items: [consumableItem, priceItem] }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "p2p-shift-inc-down", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [s.attach({ productId: "pro" })], - }); - - // Track usage within included (80 used, 200 included) - const messagesUsage = 80; - await autumnV1.track( - { +test.concurrent( + `${chalk.yellowBright("p2p: shift included usage down into overage")}`, + async () => { + const consumableItem = items.consumableMessages({ includedUsage: 200 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [consumableItem, priceItem], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "p2p-shift-inc-down", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: "pro" })], + }); + + // Track usage within included (80 used, 200 included) + const messagesUsage = 80; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + // Shift included down to 50 - puts existing usage into overage + const newConsumableItem = items.consumableMessages({ includedUsage: 50 }); + + const updateParams = { customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsage, - }, - { timeout: 2000 }, - ); - - // Shift included down to 50 - puts existing usage into overage - const newConsumableItem = items.consumableMessages({ includedUsage: 50 }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [newConsumableItem, priceItem], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // No price change, just included usage shift (consumable overage not charged on update) - expect(preview.total).toBe(0); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Usage preserved, now in overage (80 used, 50 included = 30 overage) - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: newConsumableItem.included_usage, - balance: newConsumableItem.included_usage - messagesUsage, // 50 - 80 = -30 - usage: messagesUsage, - }); - - // Verify invoice matches preview - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); + product_id: pro.id, + items: [newConsumableItem, priceItem], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // No price change, just included usage shift (consumable overage not charged on update) + expect(preview.total).toBe(0); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Usage preserved, now in overage (80 used, 50 included = 30 overage) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: newConsumableItem.included_usage, + balance: newConsumableItem.included_usage - messagesUsage, // 50 - 80 = -30 + usage: messagesUsage, + }); + + // Verify invoice matches preview + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // PAID-TO-PAID: OVERAGE BILLING MID-CYCLE // ═══════════════════════════════════════════════════════════════════════════════ // 5.1 Mid-cycle update with pending overage -test.concurrent(`${chalk.yellowBright("p2p: mid-cycle update with pending overage")}`, async () => { - const consumableItem = items.consumableMessages({ includedUsage: 50 }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ id: "pro", items: [consumableItem, priceItem] }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "p2p-pending-overage", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [s.attach({ productId: "pro" })], - }); - - // Track usage that goes over included (50 included, use 80 = 30 overage @ $0.10 = $3) - const messagesUsage = 80; - await autumnV1.track( - { +test.concurrent( + `${chalk.yellowBright("p2p: mid-cycle update with pending overage")}`, + async () => { + const consumableItem = items.consumableMessages({ includedUsage: 50 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [consumableItem, priceItem], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "p2p-pending-overage", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: "pro" })], + }); + + // Track usage that goes over included (50 included, use 80 = 30 overage @ $0.10 = $3) + const messagesUsage = 80; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + // Update to different consumable config + const newConsumableItem = items.consumableMessages({ includedUsage: 100 }); + + const updateParams = { customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsage, - }, - { timeout: 2000 }, - ); - - // Update to different consumable config - const newConsumableItem = items.consumableMessages({ includedUsage: 100 }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [newConsumableItem, priceItem], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Preview should NOT include overage charge (consumable overage not charged on update) - expect(preview.total).toBe(0); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Usage preserved, now within included - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: newConsumableItem.included_usage, - balance: newConsumableItem.included_usage - messagesUsage, - usage: messagesUsage, - }); - - // Verify invoice matches preview - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); + product_id: pro.id, + items: [newConsumableItem, priceItem], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Preview should NOT include overage charge (consumable overage not charged on update) + expect(preview.total).toBe(0); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Usage preserved, now within included + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: newConsumableItem.included_usage, + balance: newConsumableItem.included_usage - messagesUsage, + usage: messagesUsage, + }); + + // Verify invoice matches preview + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); // 5.2 Update consumable to more included covers overage -test.concurrent(`${chalk.yellowBright("p2p: update to more included covers overage")}`, async () => { - const consumableItem = items.consumableMessages({ includedUsage: 50 }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ id: "pro", items: [consumableItem, priceItem] }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "p2p-cover-overage", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [s.attach({ productId: "pro" })], - }); - - // Track usage that goes over (80 used with 50 included = 30 overage) - const messagesUsage = 80; - await autumnV1.track( - { +test.concurrent( + `${chalk.yellowBright("p2p: update to more included covers overage")}`, + async () => { + const consumableItem = items.consumableMessages({ includedUsage: 50 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [consumableItem, priceItem], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "p2p-cover-overage", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: "pro" })], + }); + + // Track usage that goes over (80 used with 50 included = 30 overage) + const messagesUsage = 80; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + // Verify customer is over their limit + const customerBefore = + await autumnV1.customers.get(customerId); + expect(customerBefore.features[TestFeature.Messages].balance).toBeLessThan( + 0, + ); + + // Update to 100 included - should now cover the usage + const newConsumableItem = items.consumableMessages({ includedUsage: 100 }); + + const updateParams = { customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsage, - }, - { timeout: 2000 }, - ); - - // Verify customer is over their limit - const customerBefore = - await autumnV1.customers.get(customerId); - expect(customerBefore.features[TestFeature.Messages].balance).toBeLessThan(0); - - // Update to 100 included - should now cover the usage - const newConsumableItem = items.consumableMessages({ includedUsage: 100 }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [newConsumableItem, priceItem], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // No price change (consumable overage not charged on update) - expect(preview.total).toBe(0); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Now usage is within included - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: newConsumableItem.included_usage, - balance: newConsumableItem.included_usage - messagesUsage, // 100 - 80 = 20 - usage: messagesUsage, - }); - - // Balance should now be positive - expect(customer.features[TestFeature.Messages].balance).toBeGreaterThan(0); - - // Verify invoice matches preview - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// PAID-TO-PAID: INTERVAL CHANGES -// ═══════════════════════════════════════════════════════════════════════════════ - -// 6.1 Monthly to annual -test.concurrent(`${chalk.yellowBright("p2p: monthly to annual")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const monthlyPriceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [messagesItem, monthlyPriceItem], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "p2p-month-to-annual", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [s.attach({ productId: "pro" })], - }); - - // Track some usage - const messagesUsage = 50; - await autumnV1.track( - { - customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsage, - }, - { timeout: 2000 }, - ); - - // Change to annual pricing - const annualPriceItem = items.annualPrice({ price: 200 }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [messagesItem, annualPriceItem], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Should charge $180 ($200 annual - $20 monthly credit) - expect(preview.total).toBe(180); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Usage should be preserved - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: messagesItem.included_usage, - balance: messagesItem.included_usage - messagesUsage, - usage: messagesUsage, - }); - - // Verify invoice matches preview - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// 6.2 Annual to monthly -test.concurrent(`${chalk.yellowBright("p2p: annual to monthly")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const annualPriceItem = items.annualPrice({ price: 200 }); - const pro = products.base({ - id: "pro", - items: [messagesItem, annualPriceItem], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "p2p-annual-to-month", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [s.attach({ productId: "pro" })], - }); - - // Track some usage - const messagesUsage = 30; - await autumnV1.track( - { - customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsage, - }, - { timeout: 2000 }, - ); - - // Change to monthly pricing - const monthlyPriceItem = items.monthlyPrice({ price: 20 }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [messagesItem, monthlyPriceItem], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Should credit $180 ($20 monthly - $200 annual credit) - expect(preview.total).toBe(-180); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Usage should be preserved - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: messagesItem.included_usage, - balance: messagesItem.included_usage - messagesUsage, - usage: messagesUsage, - }); - - // Verify invoice matches preview - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// 6.3 Change feature reset interval (month to week) -test.concurrent(`${chalk.yellowBright("p2p: change feature reset interval")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ id: "pro", items: [messagesItem, priceItem] }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "p2p-reset-interval", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [s.attach({ productId: "pro" })], - }); - - // Track some usage - const messagesUsage = 40; - await autumnV1.track( - { - customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsage, - }, - { timeout: 2000 }, - ); - - // Change to weekly reset - const weeklyMessagesItem = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Week, - }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [weeklyMessagesItem, priceItem], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // No price change, just interval change - expect(preview.total).toBe(0); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Usage should stay - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: 100, - balance: 100 - messagesUsage, - usage: messagesUsage, - }); - - // Verify interval changed - expect(customer.features[TestFeature.Messages].interval).toEqual( - EntInterval.Week, - ); - - // Verify invoice matches preview - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// PAID-TO-PAID: TIME-ADVANCED UPDATES (TEST CLOCK) -// ═══════════════════════════════════════════════════════════════════════════════ - -// 7.4 Reset cycle anchor preserved after 5 days -test.concurrent(`${chalk.yellowBright("p2p: reset anchor preserved after 5 days")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ id: "pro", items: [messagesItem, priceItem] }); - - const { customerId, autumnV1, ctx, testClockId } = await initScenario({ - customerId: "p2p-anchor-5d", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [s.attach({ productId: "pro" })], - }); - - // Track some usage - const messagesUsage = 25; - await autumnV1.track( - { - customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsage, - }, - { timeout: 2000 }, - ); - - // Get the original reset time - const customerBefore = - await autumnV1.customers.get(customerId); - const originalResetAt = - customerBefore.features[TestFeature.Messages].next_reset_at; - expect(originalResetAt).toBeDefined(); - - // Advance test clock by 5 days - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - numberOfDays: 5, - }); - - // Update with more included usage (keep same price) - const updatedMessagesItem = items.monthlyMessages({ includedUsage: 150 }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [updatedMessagesItem, priceItem], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // No price change, just included usage change - expect(preview.total).toBe(0); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Usage should stay the same, reset anchor should stay approximately the same - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: updatedMessagesItem.included_usage, - balance: updatedMessagesItem.included_usage - messagesUsage, - usage: messagesUsage, - resetsAt: originalResetAt!, - }); - - // Verify invoice matches preview - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// PAID-TO-PAID: COMBINATION UPDATES -// ═══════════════════════════════════════════════════════════════════════════════ - -// 9.3 Change interval + add feature + change usage -test.concurrent(`${chalk.yellowBright("p2p: interval + feature + usage change")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const monthlyPriceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [messagesItem, monthlyPriceItem], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "p2p-combo-complex", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [s.attach({ productId: "pro" })], - }); - - // Track some usage - const messagesUsage = 50; - await autumnV1.track( - { - customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsage, - }, - { timeout: 2000 }, - ); - - // Complex update: monthly -> annual + add words + increase messages to 200 - const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 }); - const annualPriceItem = items.annualPrice({ price: 200 }); - const wordsItem = items.monthlyWords({ includedUsage: 500 }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [updatedMessagesItem, annualPriceItem, wordsItem], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Should charge $180 ($200 annual - $20 monthly credit) - expect(preview.total).toBe(180); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Messages usage preserved, higher limit - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: updatedMessagesItem.included_usage, - balance: updatedMessagesItem.included_usage - messagesUsage, - usage: messagesUsage, - }); - - // Words added - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Words, - includedUsage: wordsItem.included_usage, - balance: wordsItem.included_usage, - usage: 0, - }); - - // Verify invoice matches preview - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); + product_id: pro.id, + items: [newConsumableItem, priceItem], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // No price change (consumable overage not charged on update) + expect(preview.total).toBe(0); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Now usage is within included + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: newConsumableItem.included_usage, + balance: newConsumableItem.included_usage - messagesUsage, // 100 - 80 = 20 + usage: messagesUsage, + }); + + // Balance should now be positive + expect(customer.features[TestFeature.Messages].balance).toBeGreaterThan(0); + + // Verify invoice matches preview + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); diff --git a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-intervals.test.ts b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-intervals.test.ts new file mode 100644 index 0000000000..484de1599d --- /dev/null +++ b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-intervals.test.ts @@ -0,0 +1,421 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + EntInterval, + ProductItemInterval, +} from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// PAID-TO-PAID: INTERVAL CHANGES +// ═══════════════════════════════════════════════════════════════════════════════ + +// 6.1 Monthly to annual +test.concurrent(`${chalk.yellowBright("p2p: monthly to annual")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const monthlyPriceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, monthlyPriceItem], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "p2p-month-to-annual", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: "pro" })], + }); + + // Track some usage + const messagesUsage = 50; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + // Change to annual pricing + const annualPriceItem = items.annualPrice({ price: 200 }); + + const updateParams = { + customer_id: customerId, + product_id: pro.id, + items: [messagesItem, annualPriceItem], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should charge $180 ($200 annual - $20 monthly credit) + expect(preview.total).toBe(180); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Usage should be preserved + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: messagesItem.included_usage, + balance: messagesItem.included_usage - messagesUsage, + usage: messagesUsage, + }); + + // Verify invoice matches preview + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// 6.2 Annual to monthly +test.concurrent(`${chalk.yellowBright("p2p: annual to monthly")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const annualPriceItem = items.annualPrice({ price: 200 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, annualPriceItem], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "p2p-annual-to-month", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: "pro" })], + }); + + // Track some usage + const messagesUsage = 30; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + // Change to monthly pricing + const monthlyPriceItem = items.monthlyPrice({ price: 20 }); + + const updateParams = { + customer_id: customerId, + product_id: pro.id, + items: [messagesItem, monthlyPriceItem], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should credit $180 ($20 monthly - $200 annual credit) + expect(preview.total).toBe(-180); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Usage should be preserved + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: messagesItem.included_usage, + balance: messagesItem.included_usage - messagesUsage, + usage: messagesUsage, + }); + + // Verify invoice matches preview + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// 6.3 Change feature reset interval (month to week) +test.concurrent( + `${chalk.yellowBright("p2p: change feature reset interval")}`, + async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ id: "pro", items: [messagesItem, priceItem] }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "p2p-reset-interval", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: "pro" })], + }); + + // Track some usage + const messagesUsage = 40; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + // Change to weekly reset + const weeklyMessagesItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + interval: ProductItemInterval.Week, + }); + + const updateParams = { + customer_id: customerId, + product_id: pro.id, + items: [weeklyMessagesItem, priceItem], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // No price change, just interval change + expect(preview.total).toBe(0); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Usage should stay + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100 - messagesUsage, + usage: messagesUsage, + }); + + // Verify interval changed + expect(customer.features[TestFeature.Messages].interval).toEqual( + EntInterval.Week, + ); + + // Verify invoice matches preview + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// PAID-TO-PAID: TIME-ADVANCED UPDATES (TEST CLOCK) +// ═══════════════════════════════════════════════════════════════════════════════ + +// 7.4 Reset cycle anchor preserved after 5 days +test.concurrent( + `${chalk.yellowBright("p2p: reset anchor preserved after 5 days")}`, + async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ id: "pro", items: [messagesItem, priceItem] }); + + const { customerId, autumnV1, ctx, testClockId } = await initScenario({ + customerId: "p2p-anchor-5d", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: "pro" })], + }); + + // Track some usage + const messagesUsage = 25; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + // Get the original reset time + const customerBefore = + await autumnV1.customers.get(customerId); + const originalResetAt = + customerBefore.features[TestFeature.Messages].next_reset_at; + expect(originalResetAt).toBeDefined(); + + // Advance test clock by 5 days + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfDays: 5, + }); + + // Update with more included usage (keep same price) + const updatedMessagesItem = items.monthlyMessages({ includedUsage: 150 }); + + const updateParams = { + customer_id: customerId, + product_id: pro.id, + items: [updatedMessagesItem, priceItem], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // No price change, just included usage change + expect(preview.total).toBe(0); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Usage should stay the same, reset anchor should stay approximately the same + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: updatedMessagesItem.included_usage, + balance: updatedMessagesItem.included_usage - messagesUsage, + usage: messagesUsage, + resetsAt: originalResetAt!, + }); + + // Verify invoice matches preview + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// PAID-TO-PAID: COMBINATION UPDATES +// ═══════════════════════════════════════════════════════════════════════════════ + +// 9.3 Change interval + add feature + change usage +test.concurrent( + `${chalk.yellowBright("p2p: interval + feature + usage change")}`, + async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const monthlyPriceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, monthlyPriceItem], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "p2p-combo-complex", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: "pro" })], + }); + + // Track some usage + const messagesUsage = 50; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + // Complex update: monthly -> annual + add words + increase messages to 200 + const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 }); + const annualPriceItem = items.annualPrice({ price: 200 }); + const wordsItem = items.monthlyWords({ includedUsage: 500 }); + + const updateParams = { + customer_id: customerId, + product_id: pro.id, + items: [updatedMessagesItem, annualPriceItem, wordsItem], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should charge $180 ($200 annual - $20 monthly credit) + expect(preview.total).toBe(180); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Messages usage preserved, higher limit + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: updatedMessagesItem.included_usage, + balance: updatedMessagesItem.included_usage - messagesUsage, + usage: messagesUsage, + }); + + // Words added + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Words, + includedUsage: wordsItem.included_usage, + balance: wordsItem.included_usage, + usage: 0, + }); + + // Verify invoice matches preview + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); diff --git a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-prepaid.test.ts b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-prepaid.test.ts index c3341dfe06..1915af2d03 100644 --- a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-prepaid.test.ts +++ b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-prepaid.test.ts @@ -31,914 +31,949 @@ import chalk from "chalk"; // ═══════════════════════════════════════════════════════════════════════════════ // Increase price per pack (same quantity) -test.concurrent(`${chalk.yellowBright("prepaid: increase price per pack")}`, async () => { - const billingUnits = 100; - const oldPrice = 10; - - const prepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits, - price: oldPrice, - }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [prepaidItem, priceItem], - }); - - const packs = 3; - const quantity = packs * billingUnits; // 300 units - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "prepaid-price-up", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }), - ], - }); - - const messagesUsed = 50; - await autumnV1.track( - { +test.concurrent( + `${chalk.yellowBright("prepaid: increase price per pack")}`, + async () => { + const billingUnits = 100; + const oldPrice = 10; + + const prepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits, + price: oldPrice, + }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [prepaidItem, priceItem], + }); + + const packs = 3; + const quantity = packs * billingUnits; // 300 units + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "prepaid-price-up", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity }], + }), + ], + }); + + const messagesUsed = 50; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsed, + }, + { timeout: 2000 }, + ); + + // Increase price from $10 to $15 per pack + const newPrice = 15; + const newPrepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits, + price: newPrice, + }); + + const updateParams = { customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsed, - }, - { timeout: 2000 }, - ); - - // Increase price from $10 to $15 per pack - const newPrice = 15; - const newPrepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits, - price: newPrice, - }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [newPrepaidItem, priceItem], - options: [{ feature_id: TestFeature.Messages, quantity }], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Refund old: 3 * $10 = $30 - // Charge new: 3 * $15 = $45 - // Total: $45 - $30 = $15 - expect(preview.total).toBe(packs * newPrice - packs * oldPrice); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // For prepaid: customer's included_usage = item's includedUsage + quantity - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: quantity, // 0 + 300 = 300 - balance: quantity - messagesUsed, - usage: messagesUsed, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); + product_id: pro.id, + items: [newPrepaidItem, priceItem], + options: [{ feature_id: TestFeature.Messages, quantity }], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Refund old: 3 * $10 = $30 + // Charge new: 3 * $15 = $45 + // Total: $45 - $30 = $15 + expect(preview.total).toBe(packs * newPrice - packs * oldPrice); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // For prepaid: customer's included_usage = item's includedUsage + quantity + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: quantity, // 0 + 300 = 300 + balance: quantity - messagesUsed, + usage: messagesUsed, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); // Decrease price per pack (same quantity) -test.concurrent(`${chalk.yellowBright("prepaid: decrease price per pack")}`, async () => { - const billingUnits = 100; - const oldPrice = 20; - - const prepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits, - price: oldPrice, - }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [prepaidItem, priceItem], - }); - - const packs = 3; - const quantity = packs * billingUnits; - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "prepaid-price-down", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }), - ], - }); - - const messagesUsed = 50; - await autumnV1.track( - { +test.concurrent( + `${chalk.yellowBright("prepaid: decrease price per pack")}`, + async () => { + const billingUnits = 100; + const oldPrice = 20; + + const prepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits, + price: oldPrice, + }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [prepaidItem, priceItem], + }); + + const packs = 3; + const quantity = packs * billingUnits; + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "prepaid-price-down", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity }], + }), + ], + }); + + const messagesUsed = 50; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsed, + }, + { timeout: 2000 }, + ); + + // Decrease price from $20 to $10 per pack + const newPrice = 10; + const newPrepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits, + price: newPrice, + }); + + const updateParams = { customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsed, - }, - { timeout: 2000 }, - ); - - // Decrease price from $20 to $10 per pack - const newPrice = 10; - const newPrepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits, - price: newPrice, - }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [newPrepaidItem, priceItem], - options: [{ feature_id: TestFeature.Messages, quantity }], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Refund old: 3 * $20 = $60 - // Charge new: 3 * $10 = $30 - // Total: $30 - $60 = -$30 (credit) - expect(preview.total).toBe(packs * newPrice - packs * oldPrice); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: quantity, // 0 + 300 = 300 - balance: quantity - messagesUsed, - usage: messagesUsed, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); + product_id: pro.id, + items: [newPrepaidItem, priceItem], + options: [{ feature_id: TestFeature.Messages, quantity }], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Refund old: 3 * $20 = $60 + // Charge new: 3 * $10 = $30 + // Total: $30 - $60 = -$30 (credit) + expect(preview.total).toBe(packs * newPrice - packs * oldPrice); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: quantity, // 0 + 300 = 300 + balance: quantity - messagesUsed, + usage: messagesUsed, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // BILLING UNITS CHANGES // ═══════════════════════════════════════════════════════════════════════════════ // Decrease billing units (same quantity = more packs) -test.concurrent(`${chalk.yellowBright("prepaid: decrease billing units (more packs)")}`, async () => { - const oldBillingUnits = 100; - const pricePerPack = 10; - - const prepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits: oldBillingUnits, - price: pricePerPack, - }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [prepaidItem, priceItem], - }); - - const quantity = 300; // 3 packs of 100 - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "prepaid-billing-units-down", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }), - ], - }); - - const messagesUsed = 50; - await autumnV1.track( - { +test.concurrent( + `${chalk.yellowBright("prepaid: decrease billing units (more packs)")}`, + async () => { + const oldBillingUnits = 100; + const pricePerPack = 10; + + const prepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits: oldBillingUnits, + price: pricePerPack, + }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [prepaidItem, priceItem], + }); + + const quantity = 300; // 3 packs of 100 + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "prepaid-billing-units-down", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity }], + }), + ], + }); + + const messagesUsed = 50; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsed, + }, + { timeout: 2000 }, + ); + + // Decrease billing units from 100 to 50 (300 units = 6 packs now) + const newBillingUnits = 50; + const newPrepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits: newBillingUnits, + price: pricePerPack, + }); + + const updateParams = { customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsed, - }, - { timeout: 2000 }, - ); - - // Decrease billing units from 100 to 50 (300 units = 6 packs now) - const newBillingUnits = 50; - const newPrepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits: newBillingUnits, - price: pricePerPack, - }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [newPrepaidItem, priceItem], - options: [{ feature_id: TestFeature.Messages, quantity }], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Old: 300 / 100 = 3 packs * $10 = $30 - // New: 300 / 50 = 6 packs * $10 = $60 - // Total: $60 - $30 = $30 - const oldPacks = Math.ceil(quantity / oldBillingUnits); - const newPacks = Math.ceil(quantity / newBillingUnits); - expect(preview.total).toBe(newPacks * pricePerPack - oldPacks * pricePerPack); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: quantity, // 0 + 300 = 300 - balance: quantity - messagesUsed, - usage: messagesUsed, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); + product_id: pro.id, + items: [newPrepaidItem, priceItem], + options: [{ feature_id: TestFeature.Messages, quantity }], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Old: 300 / 100 = 3 packs * $10 = $30 + // New: 300 / 50 = 6 packs * $10 = $60 + // Total: $60 - $30 = $30 + const oldPacks = Math.ceil(quantity / oldBillingUnits); + const newPacks = Math.ceil(quantity / newBillingUnits); + expect(preview.total).toBe( + newPacks * pricePerPack - oldPacks * pricePerPack, + ); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: quantity, // 0 + 300 = 300 + balance: quantity - messagesUsed, + usage: messagesUsed, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); // Increase billing units (same quantity = fewer packs) -test.concurrent(`${chalk.yellowBright("prepaid: increase billing units (fewer packs)")}`, async () => { - const oldBillingUnits = 50; - const pricePerPack = 10; - - const prepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits: oldBillingUnits, - price: pricePerPack, - }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [prepaidItem, priceItem], - }); - - const quantity = 300; // 6 packs of 50 - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "prepaid-billing-units-up", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }), - ], - }); - - const messagesUsed = 50; - await autumnV1.track( - { +test.concurrent( + `${chalk.yellowBright("prepaid: increase billing units (fewer packs)")}`, + async () => { + const oldBillingUnits = 50; + const pricePerPack = 10; + + const prepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits: oldBillingUnits, + price: pricePerPack, + }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [prepaidItem, priceItem], + }); + + const quantity = 300; // 6 packs of 50 + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "prepaid-billing-units-up", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity }], + }), + ], + }); + + const messagesUsed = 50; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsed, + }, + { timeout: 2000 }, + ); + + // Increase billing units from 50 to 100 (300 units = 3 packs now) + const newBillingUnits = 100; + const newPrepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits: newBillingUnits, + price: pricePerPack, + }); + + const updateParams = { customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsed, - }, - { timeout: 2000 }, - ); - - // Increase billing units from 50 to 100 (300 units = 3 packs now) - const newBillingUnits = 100; - const newPrepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits: newBillingUnits, - price: pricePerPack, - }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [newPrepaidItem, priceItem], - options: [{ feature_id: TestFeature.Messages, quantity }], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Old: 300 / 50 = 6 packs * $10 = $60 - // New: 300 / 100 = 3 packs * $10 = $30 - // Total: $30 - $60 = -$30 (credit) - const oldPacks = Math.ceil(quantity / oldBillingUnits); - const newPacks = Math.ceil(quantity / newBillingUnits); - expect(preview.total).toBe(newPacks * pricePerPack - oldPacks * pricePerPack); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: quantity, // 0 + 300 = 300 - balance: quantity - messagesUsed, - usage: messagesUsed, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); + product_id: pro.id, + items: [newPrepaidItem, priceItem], + options: [{ feature_id: TestFeature.Messages, quantity }], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Old: 300 / 50 = 6 packs * $10 = $60 + // New: 300 / 100 = 3 packs * $10 = $30 + // Total: $30 - $60 = -$30 (credit) + const oldPacks = Math.ceil(quantity / oldBillingUnits); + const newPacks = Math.ceil(quantity / newBillingUnits); + expect(preview.total).toBe( + newPacks * pricePerPack - oldPacks * pricePerPack, + ); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: quantity, // 0 + 300 = 300 + balance: quantity - messagesUsed, + usage: messagesUsed, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // INCLUDED USAGE CHANGES // ═══════════════════════════════════════════════════════════════════════════════ // Add included usage to prepaid feature -test.concurrent(`${chalk.yellowBright("prepaid: add included usage")}`, async () => { - const billingUnits = 100; - const pricePerPack = 10; - - const prepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits, - price: pricePerPack, - }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [prepaidItem, priceItem], - }); - - const packs = 2; - const quantity = packs * billingUnits; // 200 units - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "prepaid-add-included", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }), - ], - }); - - const messagesUsed = 100; - await autumnV1.track( - { +test.concurrent( + `${chalk.yellowBright("prepaid: add included usage")}`, + async () => { + const billingUnits = 100; + const pricePerPack = 10; + + const prepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits, + price: pricePerPack, + }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [prepaidItem, priceItem], + }); + + const packs = 2; + const quantity = packs * billingUnits; // 200 units + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "prepaid-add-included", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity }], + }), + ], + }); + + const messagesUsed = 100; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsed, + }, + { timeout: 2000 }, + ); + + // Add 50 included usage (free units) + const includedUsage = 100; + const newPrepaidItem = items.prepaidMessages({ + includedUsage, + billingUnits, + price: pricePerPack, + }); + + const updateParams = { customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsed, - }, - { timeout: 2000 }, - ); - - // Add 50 included usage (free units) - const includedUsage = 100; - const newPrepaidItem = items.prepaidMessages({ - includedUsage, - billingUnits, - price: pricePerPack, - }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [newPrepaidItem, priceItem], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Adding included usage doesn't change prepaid charge (same packs) - expect(preview.total).toBe(-10); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Balance = includedUsage + quantity - messagesUsed = 50 + 200 - 100 = 150 - // Customer's included_usage = item's includedUsage + quantity = 50 + 200 = 250 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: quantity, - balance: quantity - messagesUsed, - usage: messagesUsed, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); + product_id: pro.id, + items: [newPrepaidItem, priceItem], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Adding included usage doesn't change prepaid charge (same packs) + expect(preview.total).toBe(-10); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Balance = includedUsage + quantity - messagesUsed = 50 + 200 - 100 = 150 + // Customer's included_usage = item's includedUsage + quantity = 50 + 200 = 250 + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: quantity, + balance: quantity - messagesUsed, + usage: messagesUsed, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); // Remove included usage from prepaid feature -test.concurrent(`${chalk.yellowBright("prepaid: remove included usage")}`, async () => { - const billingUnits = 100; - const pricePerPack = 10; - const oldIncludedUsage = 100; - - const prepaidItem = items.prepaidMessages({ - includedUsage: oldIncludedUsage, - billingUnits, - price: pricePerPack, - }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [prepaidItem, priceItem], - }); - - const packs = 2; - const quantity = packs * billingUnits; // 200 units - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "prepaid-remove-included", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }), - ], - }); - - // Total balance = 100 (included) + 200 (prepaid) = 300 - const messagesUsed = 150; - await autumnV1.track( - { +test.concurrent( + `${chalk.yellowBright("prepaid: remove included usage")}`, + async () => { + const billingUnits = 100; + const pricePerPack = 10; + const oldIncludedUsage = 100; + + const prepaidItem = items.prepaidMessages({ + includedUsage: oldIncludedUsage, + billingUnits, + price: pricePerPack, + }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [prepaidItem, priceItem], + }); + + const packs = 2; + const quantity = packs * billingUnits; // 200 units + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "prepaid-remove-included", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity }], + }), + ], + }); + + // Total balance = 100 (included) + 200 (prepaid) = 300 + const messagesUsed = 150; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsed, + }, + { timeout: 2000 }, + ); + + // Remove included usage + const newPrepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits, + price: pricePerPack, + }); + + const updateParams = { customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsed, - }, - { timeout: 2000 }, - ); - - // Remove included usage - const newPrepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits, - price: pricePerPack, - }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [newPrepaidItem, priceItem], - options: [{ feature_id: TestFeature.Messages, quantity }], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Removing included usage doesn't change prepaid charge (same packs) - expect(preview.total).toBe(0); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Balance = 0 (included) + 200 (prepaid) - 150 (used) = 50 - // Customer's included_usage = 0 + 200 = 200 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: quantity, - balance: quantity - messagesUsed, - usage: messagesUsed, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); + product_id: pro.id, + items: [newPrepaidItem, priceItem], + options: [{ feature_id: TestFeature.Messages, quantity }], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Removing included usage doesn't change prepaid charge (same packs) + expect(preview.total).toBe(0); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Balance = 0 (included) + 200 (prepaid) - 150 (used) = 50 + // Customer's included_usage = 0 + 200 = 200 + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: quantity, + balance: quantity - messagesUsed, + usage: messagesUsed, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // COMBINED CHANGES // ═══════════════════════════════════════════════════════════════════════════════ // Change price and billing units simultaneously -test.concurrent(`${chalk.yellowBright("prepaid: change price and billing units")}`, async () => { - const oldBillingUnits = 100; - const oldPrice = 10; - - const prepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits: oldBillingUnits, - price: oldPrice, - }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [prepaidItem, priceItem], - }); - - const quantity = 300; // 3 packs of 100 - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "prepaid-price-and-units", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }), - ], - }); - - const messagesUsed = 50; - await autumnV1.track( - { +test.concurrent( + `${chalk.yellowBright("prepaid: change price and billing units")}`, + async () => { + const oldBillingUnits = 100; + const oldPrice = 10; + + const prepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits: oldBillingUnits, + price: oldPrice, + }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [prepaidItem, priceItem], + }); + + const quantity = 300; // 3 packs of 100 + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "prepaid-price-and-units", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity }], + }), + ], + }); + + const messagesUsed = 50; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsed, + }, + { timeout: 2000 }, + ); + + // Change: 100 units @ $10 -> 50 units @ $8 + const newBillingUnits = 50; + const newPrice = 8; + const newPrepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits: newBillingUnits, + price: newPrice, + }); + + const updateParams = { customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsed, - }, - { timeout: 2000 }, - ); - - // Change: 100 units @ $10 -> 50 units @ $8 - const newBillingUnits = 50; - const newPrice = 8; - const newPrepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits: newBillingUnits, - price: newPrice, - }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [newPrepaidItem, priceItem], - options: [{ feature_id: TestFeature.Messages, quantity }], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Old: 300 / 100 = 3 packs * $10 = $30 - // New: 300 / 50 = 6 packs * $8 = $48 - // Total: $48 - $30 = $18 - const oldPacks = Math.ceil(quantity / oldBillingUnits); - const newPacks = Math.ceil(quantity / newBillingUnits); - expect(preview.total).toBe(newPacks * newPrice - oldPacks * oldPrice); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: quantity, // 0 + 300 = 300 - balance: quantity - messagesUsed, - usage: messagesUsed, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); + product_id: pro.id, + items: [newPrepaidItem, priceItem], + options: [{ feature_id: TestFeature.Messages, quantity }], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Old: 300 / 100 = 3 packs * $10 = $30 + // New: 300 / 50 = 6 packs * $8 = $48 + // Total: $48 - $30 = $18 + const oldPacks = Math.ceil(quantity / oldBillingUnits); + const newPacks = Math.ceil(quantity / newBillingUnits); + expect(preview.total).toBe(newPacks * newPrice - oldPacks * oldPrice); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: quantity, // 0 + 300 = 300 + balance: quantity - messagesUsed, + usage: messagesUsed, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); // Change all: price, billing units, and included usage -test.concurrent(`${chalk.yellowBright("prepaid: change price, billing units, and included usage")}`, async () => { - const oldBillingUnits = 100; - const oldPrice = 10; - const oldIncludedUsage = 0; - - const prepaidItem = items.prepaidMessages({ - includedUsage: oldIncludedUsage, - billingUnits: oldBillingUnits, - price: oldPrice, - }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [prepaidItem, priceItem], - }); - - const quantity = 200; // 2 packs of 100 - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "prepaid-all-changes", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }), - ], - }); - - const messagesUsed = 50; - await autumnV1.track( - { +test.concurrent( + `${chalk.yellowBright("prepaid: change price, billing units, and included usage")}`, + async () => { + const oldBillingUnits = 100; + const oldPrice = 10; + const oldIncludedUsage = 0; + + const prepaidItem = items.prepaidMessages({ + includedUsage: oldIncludedUsage, + billingUnits: oldBillingUnits, + price: oldPrice, + }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [prepaidItem, priceItem], + }); + + const quantity = 200; // 2 packs of 100 + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "prepaid-all-changes", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity }], + }), + ], + }); + + const messagesUsed = 50; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsed, + }, + { timeout: 2000 }, + ); + + // Change everything: add 100 included, 50 units @ $5 + const newBillingUnits = 50; + const newPrice = 5; + const newIncludedUsage = 100; + const newPrepaidItem = items.prepaidMessages({ + includedUsage: newIncludedUsage, + billingUnits: newBillingUnits, + price: newPrice, + }); + + const updateParams = { customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsed, - }, - { timeout: 2000 }, - ); - - // Change everything: add 100 included, 50 units @ $5 - const newBillingUnits = 50; - const newPrice = 5; - const newIncludedUsage = 100; - const newPrepaidItem = items.prepaidMessages({ - includedUsage: newIncludedUsage, - billingUnits: newBillingUnits, - price: newPrice, - }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [newPrepaidItem, priceItem], - options: [{ feature_id: TestFeature.Messages, quantity }], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Old: 200 / 100 = 2 packs * $10 = $20 - // New: 200 / 50 = 4 packs * $5 = $20 - // Total: $20 - $20 = $0 - const oldPacks = Math.ceil(quantity / oldBillingUnits); - const newPacks = Math.ceil((quantity - newIncludedUsage) / newBillingUnits); - expect(preview.total).toBe(newPacks * newPrice - oldPacks * oldPrice); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Balance = quantity - messagesUsed = 200 - 50 = 150 - // Customer's included_usage = quantity = 200 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: quantity, - balance: quantity - messagesUsed, - usage: messagesUsed, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); + product_id: pro.id, + items: [newPrepaidItem, priceItem], + options: [{ feature_id: TestFeature.Messages, quantity }], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Old: 200 / 100 = 2 packs * $10 = $20 + // New: 200 / 50 = 4 packs * $5 = $20 + // Total: $20 - $20 = $0 + const oldPacks = Math.ceil(quantity / oldBillingUnits); + const newPacks = Math.ceil((quantity - newIncludedUsage) / newBillingUnits); + expect(preview.total).toBe(newPacks * newPrice - oldPacks * oldPrice); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Balance = quantity - messagesUsed = 200 - 50 = 150 + // Customer's included_usage = quantity = 200 + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: quantity, + balance: quantity - messagesUsed, + usage: messagesUsed, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); // Zero usage, change item config -test.concurrent(`${chalk.yellowBright("prepaid: zero usage, change item config")}`, async () => { - const oldBillingUnits = 100; - const oldPrice = 10; - - const prepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits: oldBillingUnits, - price: oldPrice, - }); - const priceItem = items.monthlyPrice({ price: 20 }); - const pro = products.base({ - id: "pro", - items: [prepaidItem, priceItem], - }); - - const quantity = 200; - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "prepaid-zero-usage-item-change", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }), - ], - }); - - // No usage tracked - - // Double the price - const newPrice = 20; - const newPrepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits: oldBillingUnits, - price: newPrice, - }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [newPrepaidItem, priceItem], - options: [{ feature_id: TestFeature.Messages, quantity }], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Old: 2 packs * $10 = $20 - // New: 2 packs * $20 = $40 - // Total: $40 - $20 = $20 - const packs = Math.ceil(quantity / oldBillingUnits); - expect(preview.total).toBe(packs * newPrice - packs * oldPrice); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: quantity, // 0 + 200 = 200 - balance: quantity, - usage: 0, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); +test.concurrent( + `${chalk.yellowBright("prepaid: zero usage, change item config")}`, + async () => { + const oldBillingUnits = 100; + const oldPrice = 10; + + const prepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits: oldBillingUnits, + price: oldPrice, + }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [prepaidItem, priceItem], + }); + + const quantity = 200; + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "prepaid-zero-usage-item-change", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity }], + }), + ], + }); + + // No usage tracked + + // Double the price + const newPrice = 20; + const newPrepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits: oldBillingUnits, + price: newPrice, + }); + + const updateParams = { + customer_id: customerId, + product_id: pro.id, + items: [newPrepaidItem, priceItem], + options: [{ feature_id: TestFeature.Messages, quantity }], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Old: 2 packs * $10 = $20 + // New: 2 packs * $20 = $40 + // Total: $40 - $20 = $20 + const packs = Math.ceil(quantity / oldBillingUnits); + expect(preview.total).toBe(packs * newPrice - packs * oldPrice); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: quantity, // 0 + 200 = 200 + balance: quantity, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // FREE TO PAID: PREPAID ITEM WITH ZERO QUANTITY // ═══════════════════════════════════════════════════════════════════════════════ // Update from free product to paid with prepaid item, passing 0 quantity -test.concurrent(`${chalk.yellowBright("prepaid: free to paid with zero quantity")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const free = products.base({ items: [messagesItem], id: "free" }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "free-to-prepaid-zero-qty", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [free] }), - ], - actions: [s.attach({ productId: free.id })], - }); - - // Track some usage on the free product - const messagesUsed = 30; - await autumnV1.track( - { +test.concurrent( + `${chalk.yellowBright("prepaid: free to paid with zero quantity")}`, + async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ items: [messagesItem], id: "free" }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "free-to-prepaid-zero-qty", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [free] }), + ], + actions: [s.attach({ productId: free.id })], + }); + + // Track some usage on the free product + const messagesUsed = 30; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsed, + }, + { timeout: 2000 }, + ); + + // Update to add prepaid messages item with 0 quantity + const billingUnits = 100; + const pricePerPack = 10; + const prepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits, + price: pricePerPack, + }); + const priceItem = items.monthlyPrice({ price: 20 }); + + const updateParams = { customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsed, - }, - { timeout: 2000 }, - ); - - // Update to add prepaid messages item with 0 quantity - const billingUnits = 100; - const pricePerPack = 10; - const prepaidItem = items.prepaidMessages({ - includedUsage: 0, - billingUnits, - price: pricePerPack, - }); - const priceItem = items.monthlyPrice({ price: 20 }); - - const updateParams = { - customer_id: customerId, - product_id: free.id, - items: [prepaidItem, priceItem], - options: [{ feature_id: TestFeature.Messages, quantity: 0 }], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // With 0 quantity, no packs are purchased - // Total = base price only = $20 - expect(preview.total).toBe(priceItem.price); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // With 0 quantity: includedUsage = 0, balance = 0 - messagesUsed (goes negative) - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: 0, // 0 + 0 = 0 - balance: Math.max(0, 0 - messagesUsed), // 0 - 30 = -30 (negative balance) - usage: 0, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 1, // Initial free invoice + update invoice - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); + product_id: free.id, + items: [prepaidItem, priceItem], + options: [{ feature_id: TestFeature.Messages, quantity: 0 }], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // With 0 quantity, no packs are purchased + // Total = base price only = $20 + expect(preview.total).toBe(priceItem.price); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // With 0 quantity: includedUsage = 0, balance = 0 - messagesUsed (goes negative) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 0, // 0 + 0 = 0 + // Usage carries over with no floor, so the balance goes negative. + balance: 0 - messagesUsed, + usage: messagesUsed, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, // Initial free invoice + update invoice + latestTotal: preview.total, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + }, +); diff --git a/server/tests/integration/billing/utils/waitForCustomerInvoiceStatus.ts b/server/tests/integration/billing/utils/waitForCustomerInvoiceStatus.ts new file mode 100644 index 0000000000..1d91c8b2d4 --- /dev/null +++ b/server/tests/integration/billing/utils/waitForCustomerInvoiceStatus.ts @@ -0,0 +1,26 @@ +import type { ApiCustomerV3 } from "@autumn/shared"; +import { pollUntil } from "@tests/utils/genUtils"; +import type { AutumnInt } from "@/external/autumn/autumnCli"; + +export const waitForCustomerInvoiceStatus = async ({ + autumn, + customerId, + status, + timeoutMs = 30_000, + intervalMs = 500, +}: { + autumn: AutumnInt; + customerId: string; + status: "paid" | "draft" | "open" | "void"; + timeoutMs?: number; + intervalMs?: number; +}): Promise => + pollUntil({ + fetch: () => + autumn.customers.get(customerId, { + skip_cache: "true", + }), + until: (customer) => customer.invoices?.[0]?.status === status, + timeoutMs, + intervalMs, + }); diff --git a/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-basic.test.ts b/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-basic.test.ts new file mode 100644 index 0000000000..7b79e10f10 --- /dev/null +++ b/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-basic.test.ts @@ -0,0 +1,326 @@ +/** + * TDD tests for the V2 batch reset worker (batchResetCustomerEntitlementsV2): + * basic reset behavior. + * + * Contract under test: + * Behaviors: + * - overdue free monthly ent with usage -> balance restored to allowance, + * adjustment 0, next_reset_at advanced per getNextResetAt (parity), + * cache_version incremented, one reset mutation reported + * - next_reset_at far in the past (multi-cycle) -> one run catches up to a + * future next_reset_at within one interval of now + * - month-boundary planted reset (Jan 31) -> advances per getNextResetAt + * (day-drift semantics preserved; free product, no Stripe anchor call) + * - unknown/deleted IDs -> no throw, zero mutations, zero verdicts + * - IDEMPOTENCY: re-running the worker on an already-reset (not-due) row + * is a no-op — balance and next_reset_at unchanged. The scanner + * re-enqueues across sweeps and SQS is at-least-once, so this is a hard + * requirement, not a nice-to-have. + * + * Pre-impl red expected on the idempotency test: classify has no "not due" + * check and executeResetMutations has no optimistic guard, so a redelivery + * double-resets (balance re-topped, next_reset_at pushed one more cycle). + */ + +import { expect, test } from "bun:test"; +import { customerEntitlements, EntInterval, ms } from "@autumn/shared"; +import { UTCDate } from "@date-fns/utc"; +import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expireCusEntForReset } from "@tests/utils/cusProductUtils/resetTestUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { eq, sql } from "drizzle-orm"; +import { getNextResetAt } from "@/utils/timeUtils.js"; +import { + fetchCustomerEntitlementRow, + runBatchResetV2, + waitForPostgresBalance, +} from "./batchResetV2TestUtils.js"; + +const INCLUDED_USAGE = 100; + +const initBasicScenario = async ({ + customerId, + trackValue, +}: { + customerId: string; + trackValue?: number; +}) => { + const plan = products.base({ + id: "batch-reset-basic", + items: [items.monthlyMessages({ includedUsage: INCLUDED_USAGE })], + }); + + const scenario = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [plan] })], + actions: [ + s.attach({ productId: plan.id }), + ...(trackValue + ? [ + s.track({ + featureId: TestFeature.Messages, + value: trackValue, + timeout: 3000, + }), + ] + : []), + ], + }); + + const customerEntitlement = await findCustomerEntitlement({ + ctx: scenario.ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(customerEntitlement).toBeDefined(); + + return { ...scenario, customerEntitlement: customerEntitlement! }; +}; + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 basic: regular reset restores balance and advances next_reset_at")}`, + async () => { + const customerId = "batch-reset-v2-basic-regular"; + const { ctx, customerEntitlement } = await initBasicScenario({ + customerId, + trackValue: 30, + }); + + await waitForPostgresBalance({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + expectedBalance: INCLUDED_USAGE - 30, + }); + + const pastTime = Date.now() - 1000; + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + pastTimeMs: pastTime, + }); + const rowBefore = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + + const result = await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + + // ── Contract: one reset mutation reported ─────────────────────── + expect(result.resetMutations.length).toBe(1); + expect(result.verdicts.length).toBe(0); + + // ── Contract: balance restored, adjustment cleared ────────────── + const row = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + expect(row.balance).toBe(INCLUDED_USAGE); + expect(row.adjustment).toBe(0); + + // ── Contract: next_reset_at parity with getNextResetAt ────────── + const expectedNextResetAt = getNextResetAt({ + curReset: new UTCDate(pastTime), + interval: EntInterval.Month, + intervalCount: 1, + }); + expect(row.next_reset_at).toBe(expectedNextResetAt); + expect(row.next_reset_at!).toBeGreaterThan(Date.now()); + + // ── Contract: cache_version incremented ───────────────────────── + expect(row.cache_version!).toBe((rowBefore.cache_version ?? 0) + 1); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 basic: multi-cycle catch-up lands within one interval of now")}`, + async () => { + const customerId = "batch-reset-v2-basic-catchup"; + const { ctx, customerEntitlement } = await initBasicScenario({ + customerId, + }); + + // ~6 months overdue — the reset must catch up several cycles at once. + const pastTime = Date.now() - ms.days(190); + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + pastTimeMs: pastTime, + }); + + await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + + const row = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + expect(row.next_reset_at!).toBeGreaterThan(Date.now()); + expect(row.next_reset_at!).toBeLessThanOrEqual(Date.now() + ms.days(32)); + expect(row.next_reset_at).toBe( + getNextResetAt({ + curReset: new UTCDate(pastTime), + interval: EntInterval.Month, + intervalCount: 1, + }), + ); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 basic: month-end boundary reset advances per getNextResetAt")}`, + async () => { + const customerId = "batch-reset-v2-basic-boundary"; + const { ctx, customerEntitlement } = await initBasicScenario({ + customerId, + }); + + // Jan 31 — month-end edge; catch-up walks Feb 28 etc. The anchor + // day-drift math itself is unit-tested; this asserts the V2 worker + // wires through the same helper. + const pastTime = Date.UTC(2026, 0, 31, 10, 0, 0); + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + pastTimeMs: pastTime, + }); + + await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + + const row = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + expect(row.next_reset_at).toBe( + getNextResetAt({ + curReset: new UTCDate(pastTime), + interval: EntInterval.Month, + intervalCount: 1, + }), + ); + expect(row.next_reset_at!).toBeGreaterThan(Date.now()); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 basic: legacy entities blob with null id still resets")}`, + async () => { + const customerId = "batch-reset-v2-basic-legacy-entities"; + const { ctx, customerEntitlement } = await initBasicScenario({ + customerId, + }); + + // Real prod shape: an old bug wrote an entity under the key "null" with + // a null id. Hydration is unvalidated by design, so the row must flow + // through and reset instead of erroring at the head of the scan. + await ctx.db.execute(sql` + UPDATE ${customerEntitlements} + SET entities = ${JSON.stringify({ + null: { id: null, balance: 50, adjustment: 0 }, + })}::jsonb + WHERE id = ${customerEntitlement.id} + `); + + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + pastTimeMs: Date.now() - 1000, + }); + + const result = await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + + expect(result.resetMutations.length).toBe(1); + const row = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + expect(row.balance).toBe(INCLUDED_USAGE); + expect(row.next_reset_at!).toBeGreaterThan(Date.now()); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 basic: unknown IDs are handled without mutations")}`, + async () => { + const customerId = "batch-reset-v2-basic-missing"; + const { ctx } = await initBasicScenario({ customerId }); + + const result = await runBatchResetV2({ + ctx, + customerEntitlementIds: ["cus_ent_does_not_exist_v2_test"], + }); + + expect(result.resetMutations.length).toBe(0); + expect(result.verdicts.length).toBe(0); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 basic: redelivery of an already-reset row is a no-op")}`, + async () => { + const customerId = "batch-reset-v2-basic-idempotent"; + const { ctx, customerEntitlement } = await initBasicScenario({ + customerId, + }); + + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + pastTimeMs: Date.now() - 1000, + }); + await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + + const rowAfterFirstReset = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + expect(rowAfterFirstReset.balance).toBe(INCLUDED_USAGE); + + // Usage after the reset — a second (redelivered) run must NOT wipe it. + await ctx.db + .update(customerEntitlements) + .set({ balance: INCLUDED_USAGE - 10 }) + .where(eq(customerEntitlements.id, customerEntitlement.id)); + + const secondRun = await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + + // ── Contract: not-due row produces no mutation ─────────────────── + expect(secondRun.resetMutations.length).toBe(0); + + const rowAfterSecondRun = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + // Balance written between runs survives; next_reset_at not pushed + // another cycle forward. + expect(rowAfterSecondRun.balance).toBe(INCLUDED_USAGE - 10); + expect(rowAfterSecondRun.next_reset_at).toBe( + rowAfterFirstReset.next_reset_at, + ); + }, +); diff --git a/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-cache-invalidation.test.ts b/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-cache-invalidation.test.ts new file mode 100644 index 0000000000..95886936c6 --- /dev/null +++ b/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-cache-invalidation.test.ts @@ -0,0 +1,146 @@ +/** + * TDD test for V2 batch reset cache invalidation. + * + * Contract under test: + * Side effects after a successful worker reset, per touched cusEnt: + * - V2 shared subject-balance hash: the cusEnt's field AND the aggregated + * field are deleted (batchInvalidateCustomerEntitlementBalances, via the + * customer's routed redis) + * Non-effects: + * - skip verdicts (e.g. not_due) do NOT clear caches + * + * The legacy FullCustomer cache is dead — the worker intentionally leaves it + * alone, so nothing here asserts on it. + */ + +import { expect, test } from "bun:test"; +import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expireCusEntForReset } from "@tests/utils/cusProductUtils/resetTestUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { getCtxWithCustomerRedis } from "@/external/redis/customerRedisRouting.js"; +import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js"; +import { AGGREGATED_BALANCE_FIELD } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js"; +import { runBatchResetV2 } from "./batchResetV2TestUtils.js"; + +const INCLUDED_USAGE = 100; + +/** Attaches a monthly-messages plan, warms the subject balance hash via a + * check call, and returns the routed redis + balance key for assertions. */ +const initWarmedCacheScenario = async ({ + customerId, + planId, +}: { + customerId: string; + planId: string; +}) => { + const plan = products.base({ + id: planId, + items: [items.monthlyMessages({ includedUsage: INCLUDED_USAGE })], + }); + + const { ctx, autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [plan] })], + actions: [s.attach({ productId: plan.id })], + }); + + const customerEntitlement = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(customerEntitlement).toBeDefined(); + + await autumnV2_2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + const balanceKey = buildSharedFullSubjectBalanceKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + featureId: TestFeature.Messages, + }); + const { ctx: routedCtx } = getCtxWithCustomerRedis({ ctx, customerId }); + + return { + ctx, + customerEntitlement: customerEntitlement!, + balanceKey, + routedRedis: routedCtx.redisV2, + }; +}; + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 cache: worker reset invalidates the subject balance hash")}`, + async () => { + const { ctx, customerEntitlement, balanceKey, routedRedis } = + await initWarmedCacheScenario({ + customerId: "batch-reset-v2-cache-invalidation", + planId: "cache-invalidation", + }); + + // ── Precondition: the balance hash is populated before the reset ─ + const subjectFieldBefore = await routedRedis.hget( + balanceKey, + customerEntitlement.id, + ); + expect(subjectFieldBefore).not.toBeNull(); + + await expireCusEntForReset({ + ctx, + customerId: "batch-reset-v2-cache-invalidation", + featureId: TestFeature.Messages, + pastTimeMs: Date.now() - 1000, + }); + + const result = await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + expect(result.resetMutations.length).toBe(1); + + // ── Contract: subject balance hash fields deleted ──────────────── + const subjectFieldAfter = await routedRedis.hget( + balanceKey, + customerEntitlement.id, + ); + expect(subjectFieldAfter).toBeNull(); + const aggregatedFieldAfter = await routedRedis.hget( + balanceKey, + AGGREGATED_BALANCE_FIELD, + ); + expect(aggregatedFieldAfter).toBeNull(); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 cache: skip verdicts do not clear the subject balance hash")}`, + async () => { + const { ctx, customerEntitlement, balanceKey, routedRedis } = + await initWarmedCacheScenario({ + customerId: "batch-reset-v2-cache-no-op", + planId: "cache-no-op", + }); + + expect( + await routedRedis.hget(balanceKey, customerEntitlement.id), + ).not.toBeNull(); + + // Row is NOT due — the worker must classify not_due and leave caches. + const result = await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + expect(result.resetMutations.length).toBe(0); + + expect( + await routedRedis.hget(balanceKey, customerEntitlement.id), + ).not.toBeNull(); + }, +); diff --git a/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-pooled-balances.test.ts b/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-pooled-balances.test.ts new file mode 100644 index 0000000000..d6d5d0a7fd --- /dev/null +++ b/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-pooled-balances.test.ts @@ -0,0 +1,255 @@ +/** Regression: batch reset must use pooled grants and leave subscription pools to invoice.created. */ + +import { expect, test } from "bun:test"; +import { + customerEntitlements, + EntInterval, + entitlements, + PooledBalanceResetMode, + pooledBalances, + RolloverExpiryDurationType, + rolloverConfigToSignature, +} from "@autumn/shared"; +import { buildCustomerMeteredScenario } from "@tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.js"; +import { withInsertedScenario } from "@tests/integration/db/full-subject/utils/withInsertedScenario.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { getResetEligibleCustomerEntitlementsPage } from "@/internal/customers/cusProducts/cusEnts/repos/getResetEligibleCustomerEntitlementsPage.js"; +import { + fetchCustomerEntitlementRow, + fetchRollovers, + runBatchResetV2, +} from "./batchResetV2TestUtils.js"; + +const withPooledResetScenario = async ({ + name, + granted, + resetMode, + withRollover, + run, +}: { + name: string; + granted: number; + resetMode: PooledBalanceResetMode; + withRollover: boolean; + run: (params: { + customerEntitlementId: string; + pastTime: number; + }) => Promise; +}) => { + const scenario = buildCustomerMeteredScenario({ ctx, name }); + + await withInsertedScenario({ + ctx, + scenario, + run: async () => { + const suffix = scenario.ids.internalCustomerId; + const entitlementId = `ent_pool_${suffix}`; + const customerEntitlementId = `cus_ent_pool_${suffix}`; + const pooledBalanceId = `pool_${suffix}`; + const pastTime = Date.now() - 1_000; + const sourceEntitlement = scenario.entitlements[0]; + const sourceCustomerEntitlement = scenario.customerEntitlements[0]; + const rollover = withRollover + ? { + max_percentage: 50, + length: 1, + duration: RolloverExpiryDurationType.Month, + } + : null; + + await ctx.db.insert(entitlements).values({ + ...sourceEntitlement, + id: entitlementId, + internal_product_id: null, + is_custom: true, + allowance: 0, + pooled: true, + rollover, + }); + await ctx.db.insert(customerEntitlements).values({ + ...sourceCustomerEntitlement, + id: customerEntitlementId, + customer_product_id: null, + entitlement_id: entitlementId, + balance: granted, + reset_cycle_anchor: pastTime, + next_reset_at: pastTime, + is_pooled_balance: true, + pooled_balance_id: pooledBalanceId, + pooled_contribution_id: null, + reset_by_invoice: null, + }); + await ctx.db.insert(pooledBalances).values({ + id: pooledBalanceId, + org_id: ctx.org.id, + env: ctx.env, + internal_customer_id: scenario.ids.internalCustomerId, + internal_feature_id: sourceEntitlement.internal_feature_id, + granted, + interval: EntInterval.Month, + interval_count: 1, + reset_cycle_anchor: pastTime, + reset_mode: resetMode, + stripe_subscription_id: + resetMode === PooledBalanceResetMode.Subscription + ? `sub_${suffix}` + : null, + customer_license_link_id: null, + rollover_signature: rolloverConfigToSignature({ rollover }), + customer_entitlement_id: customerEntitlementId, + last_applied_reset_at: null, + }); + + try { + await run({ customerEntitlementId, pastTime }); + } finally { + await ctx.db + .delete(pooledBalances) + .where(eq(pooledBalances.id, pooledBalanceId)); + await ctx.db + .delete(customerEntitlements) + .where(eq(customerEntitlements.id, customerEntitlementId)); + await ctx.db + .delete(entitlements) + .where(eq(entitlements.id, entitlementId)); + } + }, + }); +}; + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 pooled: lazy reset uses pooled granted for balance and rollover cap")}`, + async () => { + const granted = 400; + await withPooledResetScenario({ + name: "batch-reset-v2-pooled-lazy", + granted, + resetMode: PooledBalanceResetMode.Lazy, + withRollover: true, + run: async ({ customerEntitlementId }) => { + const result = await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlementId], + }); + + expect(result.resetMutations).toHaveLength(1); + const row = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId, + }); + expect(row.balance).toBe(granted); + + const rolloverRows = await fetchRollovers({ + db: ctx.db, + customerEntitlementId, + }); + expect( + rolloverRows.reduce( + (total, rollover) => total + (rollover.balance ?? 0), + 0, + ), + ).toBe(granted * 0.5); + }, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 pooled: subscription pool remains owned by invoice.created")}`, + async () => { + const granted = 300; + await withPooledResetScenario({ + name: "batch-reset-v2-pooled-subscription", + granted, + resetMode: PooledBalanceResetMode.Subscription, + withRollover: false, + run: async ({ customerEntitlementId, pastTime }) => { + const result = await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlementId], + }); + + expect(result.resetMutations).toHaveLength(0); + expect(result.verdicts).toEqual([ + expect.objectContaining({ + kind: "resets_via_invoice", + customerEntitlementId, + }), + ]); + + const row = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId, + }); + expect(row.balance).toBe(granted); + expect(row.next_reset_at).toBe(pastTime); + expect(row.reset_by_invoice).toBe(true); + }, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 pooled: source contribution is excluded from scans and direct resets")}`, + async () => { + const scenario = buildCustomerMeteredScenario({ + ctx, + name: "batch-reset-v2-pooled-source", + }); + await withInsertedScenario({ + ctx, + scenario, + run: async () => { + const sourceEntitlement = scenario.entitlements[0]; + const sourceCustomerEntitlement = scenario.customerEntitlements[0]; + const nextResetAt = Date.UTC(1985, 0, 1); + const dueBefore = nextResetAt + 1_000; + + await ctx.db + .update(entitlements) + .set({ pooled: true }) + .where(eq(entitlements.id, sourceEntitlement.id)); + await ctx.db + .update(customerEntitlements) + .set({ + balance: 0, + next_reset_at: nextResetAt, + pooled_contribution_id: `pbc_${scenario.ids.internalCustomerId}`, + }) + .where(eq(customerEntitlements.id, sourceCustomerEntitlement.id)); + + const scan = await getResetEligibleCustomerEntitlementsPage({ + db: ctx.db, + dueBefore, + cursor: null, + limit: 10_000, + }); + expect(scan.map(({ id }) => id)).not.toContain( + sourceCustomerEntitlement.id, + ); + + const result = await runBatchResetV2({ + ctx, + customerEntitlementIds: [sourceCustomerEntitlement.id], + }); + expect(result.resetMutations).toHaveLength(0); + expect(result.verdicts).toEqual([ + expect.objectContaining({ + kind: "no_action", + reason: "pooled_balance_source", + customerEntitlementId: sourceCustomerEntitlement.id, + }), + ]); + + const row = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: sourceCustomerEntitlement.id, + }); + expect(row.balance).toBe(0); + expect(row.next_reset_at).toBe(nextResetAt); + }, + }); + }, +); diff --git a/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-race.test.ts b/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-race.test.ts new file mode 100644 index 0000000000..21d2d64bb3 --- /dev/null +++ b/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-race.test.ts @@ -0,0 +1,131 @@ +/** + * TDD test for the V2 batch reset optimistic guard. + * + * Contract under test: + * Behaviors: + * - a mutation computed BEFORE a concurrent reset committed (the lazy + * reset racing the worker, or a duplicate delivery) is SKIPPED by the + * guarded execute UPDATE: balance written by the winner survives, + * next_reset_at is not pushed another cycle, and NO duplicate rollover + * row is inserted + * - the skip is reported (staleSkippedCount) and non-applied mutations + * produce no rollover writes + * + * The race is simulated by running the worker's stages directly: hydrate + + * classify + compute, then committing a "lazy reset" (row update + rollover + * insert) before executeResetMutations runs with the now-stale mutations. + */ + +import { expect, test } from "bun:test"; +import { + customerEntitlements, + RolloverExpiryDurationType, +} from "@autumn/shared"; +import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expireCusEntForReset } from "@tests/utils/cusProductUtils/resetTestUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { eq, sql } from "drizzle-orm"; +import { logger } from "@/external/logtail/logtailUtils.js"; +import { classifyBatchResetContext } from "@/internal/balances/batchReset/compute/classifyBatchResetContext.js"; +import { computeResetMutations } from "@/internal/balances/batchReset/compute/computeResets/computeResetMutations.js"; +import { executeResetMutations } from "@/internal/balances/batchReset/execute/executeResetMutations.js"; +import { setupBatchResetContext } from "@/internal/balances/batchReset/setup/setupBatchResetContext.js"; +import { + fetchCustomerEntitlementRow, + fetchRollovers, +} from "./batchResetV2TestUtils.js"; + +const INCLUDED_USAGE = 100; + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 race: stale mutation is skipped after a concurrent reset wins")}`, + async () => { + const customerId = "batch-reset-v2-race-lazy-wins"; + const plan = products.base({ + id: "race-lazy-wins", + items: [ + items.monthlyMessagesWithRollover({ + includedUsage: INCLUDED_USAGE, + rolloverConfig: { + max: 1000, + length: 1, + duration: RolloverExpiryDurationType.Month, + }, + }), + ], + }); + + const { ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [plan] })], + actions: [s.attach({ productId: plan.id })], + }); + + const customerEntitlement = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(customerEntitlement).toBeDefined(); + + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + pastTimeMs: Date.now() - 1000, + }); + + // ── Worker stages 1-3: hydrate, classify, compute ──────────────── + const batchResetContext = await setupBatchResetContext({ + db: ctx.db, + logger, + payload: { customerEntitlementIds: [customerEntitlement!.id] }, + }); + const classified = classifyBatchResetContext({ batchResetContext }); + const resetMutations = await computeResetMutations({ + resetGroups: classified.resetGroups, + }); + expect(resetMutations.length).toBe(1); + const computedNextResetAt = resetMutations[0].updates.next_reset_at; + + // ── Concurrent "lazy reset" commits first: same reset target, its + // own rollover row, then some fresh usage (balance 95) ─────────── + await ctx.db.execute(sql` + INSERT INTO rollovers (id, cus_ent_id, balance, usage, expires_at, entities) + VALUES ('roll_race_lazy_winner', ${customerEntitlement!.id}, 100, 0, null, '{}'::jsonb) + `); + await ctx.db + .update(customerEntitlements) + .set({ balance: 95, next_reset_at: computedNextResetAt }) + .where(eq(customerEntitlements.id, customerEntitlement!.id)); + + // ── Worker stage 4: execute with the now-stale mutations ───────── + const { appliedCustomerEntitlementIds, staleSkippedCount } = + await executeResetMutations({ db: ctx.db, resetMutations }); + + // ── Contract: the stale mutation is skipped, not double-applied ── + expect(staleSkippedCount).toBe(1); + expect(appliedCustomerEntitlementIds.size).toBe(0); + + const row = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement!.id, + }); + // The winner's post-reset usage survives (no balance re-top). + expect(row.balance).toBe(95); + // next_reset_at not pushed another cycle forward. + expect(row.next_reset_at).toBe(computedNextResetAt); + + // ── Contract: exactly ONE rollover row (the winner's) ──────────── + const rolloverRows = await fetchRollovers({ + db: ctx.db, + customerEntitlementId: customerEntitlement!.id, + }); + expect(rolloverRows.length).toBe(1); + expect(rolloverRows[0].id).toBe("roll_race_lazy_winner"); + }, +); diff --git a/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-rollovers.test.ts b/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-rollovers.test.ts new file mode 100644 index 0000000000..22e07c6c41 --- /dev/null +++ b/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-rollovers.test.ts @@ -0,0 +1,530 @@ +/** + * TDD tests for the V2 batch reset worker (batchResetCustomerEntitlementsV2): + * rollover creation and cap enforcement at reset time. + * + * Contract under test: + * Behaviors: + * - unused balance below the cap rolls over in full; cusEnt balance + * returns to the allowance + * - `max` cap: rollover is clamped to min(unused, max) + * - `max_percentage` cap: rollover is clamped to pct of the allowance + * - Month duration: rollover expires_at = old next_reset_at + length + * months; Forever duration: expires_at is null + * - multi-cycle accumulation (Forever + max): total rollover balance + * across repeated resets stays <= max — exercises the V2 + * performMaximumClearing + upsert/delete execute path + * - entity-scoped ents: each entity's balance resets to the allowance and + * rolls over per entity into rollover.entities + * Side effects: + * - rollover rows live in the `rollovers` table keyed by cus_ent_id + */ + +import { expect, test } from "bun:test"; +import { + ProductItemInterval, + type RolloverConfig, + RolloverExpiryDurationType, +} from "@autumn/shared"; +import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expireCusEntForReset } from "@tests/utils/cusProductUtils/resetTestUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { + fetchCustomerEntitlementRow, + fetchRollovers, + runBatchResetV2, + waitForPostgresBalance, +} from "./batchResetV2TestUtils.js"; + +const INCLUDED_USAGE = 100; + +/** Attach a free monthly-messages plan with the given rollover config, track + * usage, wait for PG sync, expire, and return everything needed to reset. */ +const initRolloverScenario = async ({ + customerId, + rolloverConfig, + trackValue, +}: { + customerId: string; + rolloverConfig: RolloverConfig; + trackValue: number; +}) => { + const plan = products.base({ + id: "batch-reset-rollover", + items: [ + items.monthlyMessagesWithRollover({ + includedUsage: INCLUDED_USAGE, + rolloverConfig, + }), + ], + }); + + const { ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [plan] })], + actions: [ + s.attach({ productId: plan.id }), + ...(trackValue > 0 + ? [ + s.track({ + featureId: TestFeature.Messages, + value: trackValue, + timeout: 3000, + }), + ] + : []), + ], + }); + + const customerEntitlement = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(customerEntitlement).toBeDefined(); + + if (trackValue > 0) { + await waitForPostgresBalance({ + db: ctx.db, + customerEntitlementId: customerEntitlement!.id, + expectedBalance: INCLUDED_USAGE - trackValue, + }); + } + + const pastTime = Date.now() - 1000; + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + pastTimeMs: pastTime, + }); + + return { ctx, customerEntitlement: customerEntitlement!, pastTime }; +}; + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 rollovers: unused balance below cap rolls over in full with Month expiry")}`, + async () => { + const { ctx, customerEntitlement, pastTime } = await initRolloverScenario({ + customerId: "batch-reset-v2-rollover-under-cap", + rolloverConfig: { + max: 50, + length: 1, + duration: RolloverExpiryDurationType.Month, + }, + trackValue: 70, // remaining 30 < max 50 + }); + + await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + + // ── Contract: balance back to allowance ───────────────────────── + const row = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + expect(row.balance).toBe(INCLUDED_USAGE); + + // ── Contract: one rollover row with full unused balance ───────── + const rolloverRows = await fetchRollovers({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + expect(rolloverRows.length).toBe(1); + expect(rolloverRows[0].balance).toBe(30); + + // ── Contract: Month expiry = old next_reset_at + 1 month ──────── + expect(rolloverRows[0].expires_at).toBe(addMonths(pastTime, 1).getTime()); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 rollovers: max cap clamps the rolled-over balance")}`, + async () => { + const { ctx, customerEntitlement, pastTime } = await initRolloverScenario({ + customerId: "batch-reset-v2-rollover-max-cap", + rolloverConfig: { + max: 50, + length: 1, + duration: RolloverExpiryDurationType.Month, + }, + trackValue: 20, // remaining 80 > max 50 + }); + + await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + + const rolloverRows = await fetchRollovers({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + const totalRollover = rolloverRows.reduce( + (total, rolloverRow) => total + (rolloverRow.balance ?? 0), + 0, + ); + expect(totalRollover).toBe(50); + // Clamping must not disturb the expiry of the surviving row. + expect(rolloverRows[0].expires_at).toBe(addMonths(pastTime, 1).getTime()); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 rollovers: max_percentage clamps to pct of allowance")}`, + async () => { + const { ctx, customerEntitlement, pastTime } = await initRolloverScenario({ + customerId: "batch-reset-v2-rollover-percentage", + rolloverConfig: { + max_percentage: 50, + length: 1, + duration: RolloverExpiryDurationType.Month, + }, + trackValue: 0, // remaining 100, pct cap = 50 + }); + + await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + + const rolloverRows = await fetchRollovers({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + const totalRollover = rolloverRows.reduce( + (total, rolloverRow) => total + (rolloverRow.balance ?? 0), + 0, + ); + expect(totalRollover).toBe(50); + expect(rolloverRows[0].expires_at).toBe(addMonths(pastTime, 1).getTime()); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 rollovers: multi-month length sets expires_at length months out")}`, + async () => { + const { ctx, customerEntitlement, pastTime } = await initRolloverScenario({ + customerId: "batch-reset-v2-rollover-length-3", + rolloverConfig: { + max: 50, + length: 3, + duration: RolloverExpiryDurationType.Month, + }, + trackValue: 70, // remaining 30 < max 50 + }); + + await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + + const rolloverRows = await fetchRollovers({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + expect(rolloverRows.length).toBe(1); + expect(rolloverRows[0].balance).toBe(30); + // ── Contract: expiry = old next_reset_at + `length` months ────── + expect(rolloverRows[0].expires_at).toBe(addMonths(pastTime, 3).getTime()); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 rollovers: Forever duration produces a null expires_at")}`, + async () => { + const { ctx, customerEntitlement } = await initRolloverScenario({ + customerId: "batch-reset-v2-rollover-forever", + rolloverConfig: { + max: 1000, + length: 1, + duration: RolloverExpiryDurationType.Forever, + }, + trackValue: 60, // remaining 40 + }); + + await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + + const rolloverRows = await fetchRollovers({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + expect(rolloverRows.length).toBe(1); + expect(rolloverRows[0].balance).toBe(40); + expect(rolloverRows[0].expires_at).toBeNull(); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 rollovers: multi-cycle accumulation stays capped at max")}`, + async () => { + const customerId = "batch-reset-v2-rollover-multi-cycle"; + const INCLUDED = 300; + const ROLLOVER_MAX = 450; + const RESET_CYCLES = 3; + + const fairUseItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: INCLUDED, + interval: ProductItemInterval.Hour, + intervalCount: 5, + rolloverConfig: { + max: ROLLOVER_MAX, + length: 1, + duration: RolloverExpiryDurationType.Forever, + }, + }); + const plan = products.base({ + id: "rollover-multi-cycle", + items: [fairUseItem], + }); + + const { ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [plan] })], + actions: [s.attach({ productId: plan.id })], + }); + + const customerEntitlement = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(customerEntitlement).toBeDefined(); + + // Full INCLUDED rolls over every cycle; without cap clearing the total + // would reach RESET_CYCLES * INCLUDED = 900, blowing past max 450. + for (let cycle = 0; cycle < RESET_CYCLES; cycle++) { + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + pastTimeMs: Date.now() - 1000, + }); + await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement!.id], + }); + } + + const rolloverRows = await fetchRollovers({ + db: ctx.db, + customerEntitlementId: customerEntitlement!.id, + }); + const totalRollover = rolloverRows.reduce( + (total, rolloverRow) => total + (rolloverRow.balance ?? 0), + 0, + ); + // ── Contract: EXACT clearing math — 300, 600→450, 750→450 ─────── + expect(totalRollover).toBe(ROLLOVER_MAX); + expect(rolloverRows.length).toBeLessThanOrEqual(RESET_CYCLES); + // Forever duration: every surviving row keeps a null expiry. + for (const rolloverRow of rolloverRows) { + expect(rolloverRow.expires_at).toBeNull(); + } + + const row = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement!.id, + }); + expect(row.balance).toBe(INCLUDED); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 rollovers: max clearing trims the earliest-expiring row first")}`, + async () => { + const customerId = "batch-reset-v2-rollover-clearing-order"; + const ROLLOVER_MAX = 150; + + const plan = products.base({ + id: "rollover-clearing-order", + items: [ + items.monthlyMessagesWithRollover({ + includedUsage: INCLUDED_USAGE, + rolloverConfig: { + max: ROLLOVER_MAX, + length: 1, + duration: RolloverExpiryDurationType.Month, + }, + }), + ], + }); + + const { ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [plan] })], + actions: [s.attach({ productId: plan.id })], + }); + + const customerEntitlement = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(customerEntitlement).toBeDefined(); + + // Cycle 1: rolls the full 100 with expiry = firstPastTime + 1 month. + const firstPastTime = Date.now() - 1000; + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + pastTimeMs: firstPastTime, + }); + await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement!.id], + }); + + // Cycle 2: rolls another 100 (total 200) → clearing must trim the 50 + // excess from the EARLIEST-expiring row (cycle 1's), not cycle 2's. + const secondPastTime = Date.now() - 1000; + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + pastTimeMs: secondPastTime, + }); + await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement!.id], + }); + + const rolloverRows = await fetchRollovers({ + db: ctx.db, + customerEntitlementId: customerEntitlement!.id, + }); + expect(rolloverRows.length).toBe(2); + + const sortedRows = [...rolloverRows].sort( + (a, b) => (a.expires_at ?? 0) - (b.expires_at ?? 0), + ); + // ── Contract: earliest-expiring row trimmed to absorb the excess ─ + expect(sortedRows[0].balance).toBe(50); + expect(sortedRows[0].expires_at).toBe( + addMonths(firstPastTime, 1).getTime(), + ); + // ── Contract: newest row untouched, expiry from its own cycle ─── + expect(sortedRows[1].balance).toBe(INCLUDED_USAGE); + expect(sortedRows[1].expires_at).toBe( + addMonths(secondPastTime, 1).getTime(), + ); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 rollovers: entity-scoped balances reset and roll over per entity")}`, + async () => { + const customerId = "batch-reset-v2-rollover-entity"; + + const entityScopedItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: INCLUDED_USAGE, + entityFeatureId: TestFeature.Users, + rolloverConfig: { + max: 1000, + length: 1, + duration: RolloverExpiryDurationType.Month, + }, + }); + const plan = products.base({ + id: "rollover-entity", + items: [entityScopedItem], + }); + + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [plan] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: plan.id }), + s.track({ + featureId: TestFeature.Messages, + value: 40, + entityIndex: 0, + timeout: 3000, + }), + ], + }); + + const customerEntitlement = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(customerEntitlement).toBeDefined(); + + // Wait for the entity deduction to land in PG (Redis-path sync lag). + const startedAt = Date.now(); + while (true) { + const row = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement!.id, + }); + const balances = Object.values(row.entities ?? {}); + if (balances.some((entity) => entity.balance === INCLUDED_USAGE - 40)) { + break; + } + if (Date.now() - startedAt > 15_000) { + throw new Error( + `entity deduction never synced to PG: ${JSON.stringify(row.entities)}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + const pastTime = Date.now() - 1000; + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + pastTimeMs: pastTime, + }); + await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement!.id], + }); + + // ── Contract: every entity balance reset to the allowance ─────── + const row = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement!.id, + }); + const entityBalances = Object.values(row.entities ?? {}); + expect(entityBalances.length).toBe(2); + for (const entity of entityBalances) { + expect(entity.balance).toBe(INCLUDED_USAGE); + expect(entity.adjustment).toBe(0); + } + + // ── Contract: per-entity rollover amounts (60 and 100) ────────── + const rolloverRows = await fetchRollovers({ + db: ctx.db, + customerEntitlementId: customerEntitlement!.id, + }); + expect(rolloverRows.length).toBe(1); + const rolloverEntities = Object.values( + (rolloverRows[0].entities ?? {}) as Record, + ); + const rolloverBalances = rolloverEntities + .map((entity) => entity.balance) + .sort((a, b) => a - b); + expect(rolloverBalances).toEqual([INCLUDED_USAGE - 40, INCLUDED_USAGE]); + // ── Contract: entity rollover carries the Month expiry too ────── + expect(rolloverRows[0].expires_at).toBe(addMonths(pastTime, 1).getTime()); + }, +); diff --git a/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-skips.test.ts b/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-skips.test.ts new file mode 100644 index 0000000000..7931ca0ea2 --- /dev/null +++ b/server/tests/integration/cron/batch-reset-v2/batch-reset-v2-skips.test.ts @@ -0,0 +1,345 @@ +/** + * TDD tests for the V2 batch reset worker (batchResetCustomerEntitlementsV2): + * candidates that must NOT be reset, and the verdicts they receive. + * + * Contract under test: + * Behaviors: + * - price-backed ent on a live subscription -> verdict + * "resets_via_invoice"; balance AND next_reset_at untouched (that reset + * is owned by the invoice.created webhook); reset_by_invoice = true is + * persisted so the scan stops re-picking the row + * - past_due product WITHOUT config.ignore_past_due -> verdict + * "no_action" (reason product_past_due); untouched + * - past_due product WITH config.ignore_past_due = true -> resets normally + * - expired product -> verdict "should_expire"; cusEnt marked expired + * - entitlement flipped to unlimited -> verdict "clear_next_reset" + * (unlimited: true); no reset mutation + * Side effects: + * - should_expire persists the denormalized cusEnt expiry flag. + */ + +import { expect, test } from "bun:test"; +import { + AllowanceType, + CusProductStatus, + customerProducts, + entitlements, +} from "@autumn/shared"; +import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expireCusEntForReset } from "@tests/utils/cusProductUtils/resetTestUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { getResetEligibleCustomerEntitlementsPage } from "@/internal/customers/cusProducts/cusEnts/repos/getResetEligibleCustomerEntitlementsPage.js"; +import { + fetchCustomerEntitlementRow, + runBatchResetV2, + waitForPostgresBalance, +} from "./batchResetV2TestUtils.js"; + +const INCLUDED_USAGE = 100; + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 skips: price-backed ent on live subscription gets resets_via_invoice verdict")}`, + async () => { + const customerId = "batch-reset-v2-skip-price-backed"; + const plan = products.pro({ + id: "skip-price-backed", + items: [items.prepaidMessages({ billingUnits: 100 })], + }); + + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [plan] }), + ], + actions: [ + s.billing.attach({ + productId: plan.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + }), + ], + }); + + const customerEntitlement = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(customerEntitlement).toBeDefined(); + + const pastTime = Date.now() - 1000; + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + pastTimeMs: pastTime, + }); + const rowBefore = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement!.id, + }); + + const result = await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement!.id], + }); + + // ── Contract: resets_via_invoice verdict, no mutation ─────────── + expect(result.resetMutations.length).toBe(0); + expect(result.verdicts).toEqual([ + expect.objectContaining({ + kind: "resets_via_invoice", + customerEntitlementId: customerEntitlement!.id, + }), + ]); + + // ── Contract: row untouched, but flagged reset_by_invoice ─────── + const rowAfter = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement!.id, + }); + expect(rowAfter.balance).toBe(rowBefore.balance); + expect(rowAfter.next_reset_at).toBe(pastTime); + expect(rowAfter.reset_by_invoice).toBe(true); + + // ── Contract: the scan no longer picks the flagged row up ─────── + const page = await getResetEligibleCustomerEntitlementsPage({ + db: ctx.db, + dueBefore: Date.now(), + cursor: null, + limit: 10_000, + }); + expect(page.map((row) => row.id)).not.toContain(customerEntitlement!.id); + }, +); + +const initPastDueScenario = async ({ + customerId, + ignorePastDue, + productStatus, +}: { + customerId: string; + ignorePastDue: boolean; + productStatus: CusProductStatus; +}) => { + const plan = products.base({ + id: "skip-status", + items: [items.monthlyMessages({ includedUsage: INCLUDED_USAGE })], + }); + plan.config = { ignore_past_due: ignorePastDue }; + + const { ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [plan] })], + actions: [ + s.attach({ productId: plan.id }), + s.track({ + featureId: TestFeature.Messages, + value: 25, + timeout: 3000, + }), + ], + }); + + const customerEntitlement = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(customerEntitlement).toBeDefined(); + expect(customerEntitlement!.customer_product_id).toBeDefined(); + + const pastTime = Date.now() - 1000; + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + pastTimeMs: pastTime, + }); + await ctx.db + .update(customerProducts) + .set({ status: productStatus }) + .where(eq(customerProducts.id, customerEntitlement!.customer_product_id!)); + + return { ctx, customerEntitlement: customerEntitlement!, pastTime }; +}; + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 skips: past_due product without ignore_past_due gets no_action verdict")}`, + async () => { + const { ctx, customerEntitlement, pastTime } = await initPastDueScenario({ + customerId: "batch-reset-v2-skip-past-due", + ignorePastDue: false, + productStatus: CusProductStatus.PastDue, + }); + + const result = await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + + expect(result.resetMutations.length).toBe(0); + expect(result.verdicts).toEqual([ + expect.objectContaining({ + kind: "no_action", + reason: "product_past_due", + customerEntitlementId: customerEntitlement.id, + }), + ]); + + const rowAfter = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + expect(rowAfter.next_reset_at).toBe(pastTime); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 skips: past_due product WITH ignore_past_due resets normally")}`, + async () => { + const { ctx, customerEntitlement } = await initPastDueScenario({ + customerId: "batch-reset-v2-skip-past-due-ignored", + ignorePastDue: true, + productStatus: CusProductStatus.PastDue, + }); + + const result = await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + + expect(result.resetMutations.length).toBe(1); + + const rowAfter = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + expect(rowAfter.balance).toBe(INCLUDED_USAGE); + expect(rowAfter.next_reset_at!).toBeGreaterThan(Date.now()); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 skips: scheduled product gets no_action verdict (lazy-path parity)")}`, + async () => { + const { ctx, customerEntitlement, pastTime } = await initPastDueScenario({ + customerId: "batch-reset-v2-skip-scheduled", + ignorePastDue: false, + productStatus: CusProductStatus.Scheduled, + }); + await waitForPostgresBalance({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + expectedBalance: INCLUDED_USAGE - 25, + }); + + const result = await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + + expect(result.resetMutations.length).toBe(0); + expect(result.verdicts).toEqual([ + expect.objectContaining({ + kind: "no_action", + reason: "product_not_active", + customerEntitlementId: customerEntitlement.id, + }), + ]); + + const rowAfter = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + expect(rowAfter.balance).toBe(INCLUDED_USAGE - 25); + expect(rowAfter.next_reset_at).toBe(pastTime); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 skips: expired product marks its customer entitlement expired")}`, + async () => { + const { ctx, customerEntitlement, pastTime } = await initPastDueScenario({ + customerId: "batch-reset-v2-skip-expired-product", + ignorePastDue: false, + productStatus: CusProductStatus.Expired, + }); + + const result = await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement.id], + }); + + expect(result.resetMutations.length).toBe(0); + expect(result.verdicts).toEqual([ + expect.objectContaining({ + kind: "should_expire", + customerEntitlementId: customerEntitlement.id, + }), + ]); + + const rowAfter = await fetchCustomerEntitlementRow({ + db: ctx.db, + customerEntitlementId: customerEntitlement.id, + }); + expect(rowAfter.expired).toBe(true); + expect(rowAfter.next_reset_at).toBe(pastTime); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 skips: entitlement flipped to unlimited gets clear_next_reset verdict")}`, + async () => { + const customerId = "batch-reset-v2-skip-unlimited"; + const plan = products.base({ + id: "skip-unlimited", + items: [items.monthlyMessages({ includedUsage: INCLUDED_USAGE })], + }); + + const { ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [plan] })], + actions: [s.attach({ productId: plan.id })], + }); + + const customerEntitlement = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(customerEntitlement).toBeDefined(); + + // Simulate the catalog entitlement having become unlimited while the + // cusEnt still carries a stale next_reset_at. + await ctx.db + .update(entitlements) + .set({ allowance_type: AllowanceType.Unlimited }) + .where(eq(entitlements.id, customerEntitlement!.entitlement_id)); + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + pastTimeMs: Date.now() - 1000, + }); + + const result = await runBatchResetV2({ + ctx, + customerEntitlementIds: [customerEntitlement!.id], + }); + + expect(result.resetMutations.length).toBe(0); + expect(result.verdicts).toEqual([ + expect.objectContaining({ + kind: "clear_next_reset", + unlimited: true, + customerEntitlementId: customerEntitlement!.id, + }), + ]); + }, +); diff --git a/server/tests/integration/cron/batch-reset-v2/batchResetV2TestUtils.ts b/server/tests/integration/cron/batch-reset-v2/batchResetV2TestUtils.ts new file mode 100644 index 0000000000..3e0b0e8c37 --- /dev/null +++ b/server/tests/integration/cron/batch-reset-v2/batchResetV2TestUtils.ts @@ -0,0 +1,82 @@ +import { customerEntitlements, rollovers } from "@autumn/shared"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; +import { eq } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { logger } from "@/external/logtail/logtailUtils.js"; +import { batchResetCustomerEntitlementsV2 } from "@/internal/balances/batchReset/batchResetCustomerEntitlementsV2.js"; + +/** Reads a cusEnt row directly from Postgres — never triggers a lazy reset + * (CusService.getFull would reset overdue rows as a side effect). */ +export const fetchCustomerEntitlementRow = async ({ + db, + customerEntitlementId, +}: { + db: DrizzleCli; + customerEntitlementId: string; +}) => { + const rows = await db + .select() + .from(customerEntitlements) + .where(eq(customerEntitlements.id, customerEntitlementId)); + if (rows.length === 0) { + throw new Error(`cusEnt ${customerEntitlementId} not found`); + } + return rows[0]; +}; + +export const fetchRollovers = async ({ + db, + customerEntitlementId, +}: { + db: DrizzleCli; + customerEntitlementId: string; +}) => + db + .select() + .from(rollovers) + .where(eq(rollovers.cus_ent_id, customerEntitlementId)); + +/** Runs the V2 batch reset worker exactly like the SQS consumer does. */ +export const runBatchResetV2 = async ({ + ctx, + customerEntitlementIds, +}: { + ctx: TestContext; + customerEntitlementIds: string[]; +}) => + batchResetCustomerEntitlementsV2({ + db: ctx.db, + logger, + payload: { customerEntitlementIds }, + }); + +/** + * Waits for a tracked deduction to land in Postgres. Redis-path track flushes + * to PG lazily (SyncV4), so asserting/expiring before the flush would race. + */ +export const waitForPostgresBalance = async ({ + db, + customerEntitlementId, + expectedBalance, + timeoutMs = 15_000, +}: { + db: DrizzleCli; + customerEntitlementId: string; + expectedBalance: number; + timeoutMs?: number; +}) => { + const startedAt = Date.now(); + while (true) { + const row = await fetchCustomerEntitlementRow({ + db, + customerEntitlementId, + }); + if (row.balance === expectedBalance) return row; + if (Date.now() - startedAt > timeoutMs) { + throw new Error( + `cusEnt ${customerEntitlementId} balance never reached ${expectedBalance} (last: ${row.balance})`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } +}; diff --git a/server/tests/integration/cron/batch-reset-v2/reset-scan-eligibility.test.ts b/server/tests/integration/cron/batch-reset-v2/reset-scan-eligibility.test.ts new file mode 100644 index 0000000000..c0e7d9a65e --- /dev/null +++ b/server/tests/integration/cron/batch-reset-v2/reset-scan-eligibility.test.ts @@ -0,0 +1,250 @@ +/** + * TDD tests for the V2 reset scan page query + * (customerEntitlementsRepo.getResetEligibleCustomerEntitlementsPage). + * + * Contract under test: + * Behaviors: + * - includes rows with next_reset_at < dueBefore + * - excludes rows with next_reset_at >= dueBefore + * - excludes rows with expired = true; includes expired = null AND + * expired = false (sticky manual false) + * - excludes rows with reset_by_invoice = true (invoice.created owns + * their reset); includes null AND false + * - excludes loose-expiry rows with expires_at <= dueBefore; includes + * expires_at > dueBefore + * - orders by (next_reset_at, id COLLATE "C"), id as tiebreak + * - keyset pagination: pages are disjoint, complete, and respect limit + * + * Isolation strategy: each test plants its rows at UNIQUE ancient + * next_reset_at values (1970s-1980s) and scans with a dueBefore just above + * its own window — rows from other tests/suites (which use ~now timestamps) + * can never enter the window, so exact-membership assertions are safe. + */ + +import { expect, test } from "bun:test"; +import { customerEntitlements, ms } from "@autumn/shared"; +import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { getResetEligibleCustomerEntitlementsPage } from "@/internal/customers/cusProducts/cusEnts/repos/getResetEligibleCustomerEntitlementsPage.js"; + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 scan: eligibility filters (overdue, expired, expires_at)")}`, + async () => { + const customerId = "batch-reset-v2-scan-eligibility"; + // Unique ancient window for this test only. + const windowStart = Date.UTC(1975, 0, 1); + const dueBefore = windowStart + ms.days(1); + + const plan = products.base({ + id: "scan-eligibility", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyCredits({ includedUsage: 100 }), + ], + }); + + const { ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [plan] })], + actions: [s.attach({ productId: plan.id })], + }); + + const messagesEnt = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + const creditsEnt = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Credits, + }); + expect(messagesEnt).toBeDefined(); + expect(creditsEnt).toBeDefined(); + + const fetchIdsInWindow = async () => { + const page = await getResetEligibleCustomerEntitlementsPage({ + db: ctx.db, + dueBefore, + cursor: null, + limit: 1000, + }); + return page.map((row) => row.id); + }; + + // ── Overdue row is included; future row is not ────────────────── + await ctx.db + .update(customerEntitlements) + .set({ next_reset_at: windowStart + 1000 }) + .where(eq(customerEntitlements.id, messagesEnt!.id)); + await ctx.db + .update(customerEntitlements) + .set({ next_reset_at: dueBefore + ms.days(365) }) + .where(eq(customerEntitlements.id, creditsEnt!.id)); + + let ids = await fetchIdsInWindow(); + expect(ids).toContain(messagesEnt!.id); + expect(ids).not.toContain(creditsEnt!.id); + + // ── expired = true is excluded ────────────────────────────────── + await ctx.db + .update(customerEntitlements) + .set({ expired: true }) + .where(eq(customerEntitlements.id, messagesEnt!.id)); + + ids = await fetchIdsInWindow(); + expect(ids).not.toContain(messagesEnt!.id); + + // ── expired = false (sticky manual false) is included ─────────── + await ctx.db + .update(customerEntitlements) + .set({ expired: false }) + .where(eq(customerEntitlements.id, messagesEnt!.id)); + + ids = await fetchIdsInWindow(); + expect(ids).toContain(messagesEnt!.id); + + // ── reset_by_invoice = true is excluded ───────────────────────── + await ctx.db + .update(customerEntitlements) + .set({ reset_by_invoice: true }) + .where(eq(customerEntitlements.id, messagesEnt!.id)); + + ids = await fetchIdsInWindow(); + expect(ids).not.toContain(messagesEnt!.id); + + // ── reset_by_invoice = false is included ──────────────────────── + await ctx.db + .update(customerEntitlements) + .set({ reset_by_invoice: false }) + .where(eq(customerEntitlements.id, messagesEnt!.id)); + + ids = await fetchIdsInWindow(); + expect(ids).toContain(messagesEnt!.id); + + // ── expires_at in the past is excluded ────────────────────────── + await ctx.db + .update(customerEntitlements) + .set({ expires_at: windowStart + 500 }) + .where(eq(customerEntitlements.id, messagesEnt!.id)); + + ids = await fetchIdsInWindow(); + expect(ids).not.toContain(messagesEnt!.id); + + // ── expires_at in the future is included ──────────────────────── + await ctx.db + .update(customerEntitlements) + .set({ expires_at: dueBefore + ms.days(365) }) + .where(eq(customerEntitlements.id, messagesEnt!.id)); + + ids = await fetchIdsInWindow(); + expect(ids).toContain(messagesEnt!.id); + }, +); + +test.concurrent( + `${chalk.yellowBright("batch-reset-v2 scan: keyset pagination is ordered, disjoint and complete")}`, + async () => { + const customerId = "batch-reset-v2-scan-pagination"; + const otherCustomerId = "batch-reset-v2-scan-pagination-b"; + // Unique ancient window for this test only. Must be the LOWEST window + // in this file: a scan at dueBefore includes everything older, so the + // strict toEqual below only holds if no other test plants rows below + // this window. + const windowStart = Date.UTC(1971, 0, 1); + const dueBefore = windowStart + ms.days(1); + + const plan = products.base({ + id: "scan-pagination", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyCredits({ includedUsage: 100 }), + ], + }); + + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.otherCustomers([{ id: otherCustomerId }]), + s.products({ list: [plan] }), + ], + actions: [ + s.attach({ productId: plan.id }), + s.attach({ productId: plan.id, customerId: otherCustomerId }), + ], + }); + + const rows = [ + await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }), + await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Credits, + }), + await findCustomerEntitlement({ + ctx, + customerId: otherCustomerId, + featureId: TestFeature.Messages, + }), + await findCustomerEntitlement({ + ctx, + customerId: otherCustomerId, + featureId: TestFeature.Credits, + }), + ]; + for (const row of rows) expect(row).toBeDefined(); + + // Two rows share the same next_reset_at to exercise the id tiebreak. + const plantedResetAts = [ + windowStart + 1000, + windowStart + 2000, + windowStart + 2000, + windowStart + 3000, + ]; + for (let i = 0; i < rows.length; i++) { + await ctx.db + .update(customerEntitlements) + .set({ next_reset_at: plantedResetAts[i] }) + .where(eq(customerEntitlements.id, rows[i]!.id)); + } + + const expectedOrder = rows + .map((row, i) => ({ id: row!.id, nextResetAt: plantedResetAts[i] })) + .sort((a, b) => a.nextResetAt - b.nextResetAt || (a.id < b.id ? -1 : 1)) + .map((row) => row.id); + + // ── Page size is respected; pages are disjoint and complete ───── + const collected: string[] = []; + let cursor: { nextResetAt: number; id: string } | null = null; + let pages = 0; + while (true) { + const page = await getResetEligibleCustomerEntitlementsPage({ + db: ctx.db, + dueBefore, + cursor, + limit: 2, + }); + pages++; + expect(page.length).toBeLessThanOrEqual(2); + if (page.length === 0) break; + collected.push(...page.map((row) => row.id)); + const lastRow = page[page.length - 1]; + cursor = { nextResetAt: lastRow.nextResetAt, id: lastRow.id }; + if (page.length < 2) break; + expect(pages).toBeLessThan(10); + } + + // ── Exactly our 4 rows, in (next_reset_at, id) order, no dupes ── + expect(collected).toEqual(expectedOrder); + }, +); diff --git a/server/tests/integration/crud/customers/auto-topup-purchase-limit-count.test.ts b/server/tests/integration/crud/customers/auto-topup-purchase-limit-count.test.ts new file mode 100644 index 0000000000..2633482dd6 --- /dev/null +++ b/server/tests/integration/crud/customers/auto-topup-purchase-limit-count.test.ts @@ -0,0 +1,568 @@ +/** + * TDD test for writable `purchase_limit.count` on customers.update. + * + * Contract under test: + * New types/fields: + * - billing_controls.auto_topups[].purchase_limit.count?: number (min 0) + * on customer create/update params only + * - next_reset_at / source remain response-only + * + * New behaviors: + * - count: N → writes auto_topup_limit_states.purchase_count = N + * for (customer, feature_id) + * - omit count → leave runtime state unchanged + * - count > limit → 400 + * - purchase_limit with only count (no interval/limit) → 400 + * - count: 0 on active window → preserve purchase_window_ends_at + * - count > 0 with stale/missing window → init future window so expand + * does not project the written count back to 0 + * - count is stripped before JSONB write (never on customers.auto_topups) + * + * Side effects: + * - upserts/updates auto_topup_limit_states row + * - still replaces customers.auto_topups config as today + * + * Pre-impl red: every assertion below fails because customers.update ignores + * count (zod strips it) and never touches auto_topup_limit_states. + * Post-impl green: sync helper writes purchase_count (+ window when needed) + * and strips count from JSONB storage. + * + * Note: this file avoids initScenario/Stripe — this cloud env has no + * STRIPE_SANDBOX_SECRET_KEY. Customer create/update + direct limit-state + * inserts are enough to cover the contract. + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + ApiVersion, + AppEnv, + CustomerExpand, + ErrCode, + PurchaseLimitInterval, +} from "@autumn/shared"; +import { makeAutoTopupConfig } from "@tests/integration/balances/auto-topup/utils/makeAutoTopupConfig"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import chalk from "chalk"; +import { initDrizzle } from "@/db/initDrizzle.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { autoTopupLimitRepo } from "@/internal/balances/autoTopUp/repos"; +import { CusService } from "@/internal/customers/CusService.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { generateId } from "@/utils/genUtils.js"; + +type ExpandedPurchaseLimit = { + interval: PurchaseLimitInterval | null; + interval_count: number | null; + limit: number | null; + count: number; + next_reset_at: number; +}; + +const createClients = async () => { + // Prefer TESTS_ORG when present; fall back so this file can run in the + // Cursor cloud VM where createTestContext fails without Stripe secrets + // (preload only builds the default ctx when TESTS_ORG is set). + const orgSlug = process.env.TESTS_ORG || "unit-test-org"; + const orgSecretKey = process.env.UNIT_TEST_AUTUMN_SECRET_KEY; + if (!orgSecretKey) throw new Error("UNIT_TEST_AUTUMN_SECRET_KEY is required"); + + const { db } = initDrizzle(); + const org = await OrgService.getBySlug({ db, slug: orgSlug }); + if (!org) throw new Error(`Org ${orgSlug} not found`); + + const ctx = { + db, + org, + env: AppEnv.Sandbox, + } as AutumnContext; + + const autumn = new AutumnInt({ + version: ApiVersion.V2_2, + secretKey: orgSecretKey, + }); + + return { autumn, ctx, org }; +}; + +const ensureCustomer = async ({ + autumn, + customerId, +}: { + autumn: AutumnInt; + customerId: string; +}) => { + try { + await autumn.customers.delete(customerId); + } catch { + // ignore missing + } + + await autumn.customers.create({ + id: customerId, + name: customerId, + email: `${customerId}@example.com`, + }); +}; + +const getExpandedPurchaseLimit = async ({ + autumn, + customerId, +}: { + autumn: AutumnInt; + customerId: string; +}) => { + const customer = await autumn.customers.get(customerId, { + expand: [CustomerExpand.AutoTopupsPurchaseLimit], + skip_cache: "true", + }); + return customer.billing_controls?.auto_topups?.[0]?.purchase_limit as + | ExpandedPurchaseLimit + | undefined; +}; + +test.concurrent( + `${chalk.yellowBright("customers.update: purchase_limit.count writes runtime state")}`, + async () => { + const customerId = "atu-pl-count-write"; + const { autumn, ctx, org } = await createClients(); + await ensureCustomer({ autumn, customerId }); + + const customer = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: org.id, + env: ctx.env, + }); + if (!customer) throw new Error("customer missing after create"); + + await autumn.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 50, + quantity: 100, + purchaseLimit: { interval: PurchaseLimitInterval.Month, limit: 5 }, + }), + }); + + const activeWindowEndsAt = Date.now() + 7 * 24 * 60 * 60 * 1000; + const now = Date.now(); + await autoTopupLimitRepo.insert({ + ctx, + data: { + id: generateId("atlim"), + internal_customer_id: customer.internal_id, + customer_id: customerId, + feature_id: TestFeature.Messages, + purchase_window_ends_at: activeWindowEndsAt, + purchase_count: 4, + attempt_window_ends_at: now, + attempt_count: 0, + failed_attempt_window_ends_at: now, + failed_attempt_count: 0, + updated_at: now, + }, + }); + + // ── Contract 1: count: 0 resets purchase_count and preserves window ─ + await autumn.customers.update(customerId, { + billing_controls: { + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold: 50, + quantity: 100, + purchase_limit: { + interval: PurchaseLimitInterval.Month, + interval_count: 1, + limit: 5, + count: 0, + }, + }, + ], + }, + }); + + const afterReset = await getExpandedPurchaseLimit({ + autumn, + customerId, + }); + expect(afterReset).toMatchObject({ + interval: PurchaseLimitInterval.Month, + interval_count: 1, + limit: 5, + count: 0, + }); + expect(afterReset?.next_reset_at).toBe(activeWindowEndsAt); + + // ── Contract 2: omit count → leave runtime state unchanged ───────── + await autumn.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 40, + quantity: 100, + purchaseLimit: { interval: PurchaseLimitInterval.Month, limit: 5 }, + }), + }); + + const afterOmit = await getExpandedPurchaseLimit({ + autumn, + customerId, + }); + expect(afterOmit).toMatchObject({ + count: 0, + next_reset_at: activeWindowEndsAt, + }); + + // ── Contract 3: count is not persisted on customers.auto_topups JSONB + const dbCustomer = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: org.id, + env: ctx.env, + }); + const storedPurchaseLimit = dbCustomer?.auto_topups?.[0]?.purchase_limit as + | Record + | undefined; + expect(storedPurchaseLimit).toEqual({ + interval: PurchaseLimitInterval.Month, + interval_count: 1, + limit: 5, + }); + expect(storedPurchaseLimit).not.toHaveProperty("count"); + + // ── Contract 4: count > limit → 400 ──────────────────────────────── + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + func: async () => + await autumn.customers.update(customerId, { + billing_controls: { + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold: 50, + quantity: 100, + purchase_limit: { + interval: PurchaseLimitInterval.Month, + interval_count: 1, + limit: 5, + count: 6, + }, + }, + ], + }, + }), + }); + + // ── Contract 5: purchase_limit with only count → 400 (zod) ───────── + await expectAutumnError({ + func: async () => + await autumn.customers.update(customerId, { + billing_controls: { + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold: 50, + quantity: 100, + purchase_limit: { + count: 0, + } as unknown as { + interval: PurchaseLimitInterval; + limit: number; + count: number; + }, + }, + ], + }, + }), + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("customers.update: purchase_limit.count re-inits stale window")}`, + async () => { + const customerId = "atu-pl-count-stale"; + const { autumn, ctx, org } = await createClients(); + await ensureCustomer({ autumn, customerId }); + + const customer = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: org.id, + env: ctx.env, + }); + if (!customer) throw new Error("customer missing after create"); + + await autumn.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 50, + quantity: 100, + purchaseLimit: { interval: PurchaseLimitInterval.Month, limit: 5 }, + }), + }); + + const staleWindowEndsAt = Date.now() - 6 * 24 * 60 * 60 * 1000; + const now = Date.now(); + await autoTopupLimitRepo.insert({ + ctx, + data: { + id: generateId("atlim"), + internal_customer_id: customer.internal_id, + customer_id: customerId, + feature_id: TestFeature.Messages, + purchase_window_ends_at: staleWindowEndsAt, + purchase_count: 3, + attempt_window_ends_at: now, + attempt_count: 0, + failed_attempt_window_ends_at: now, + failed_attempt_count: 0, + updated_at: now, + }, + }); + + // ── Contract 6: count > 0 on stale window → persist count + future window + await autumn.customers.update(customerId, { + billing_controls: { + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold: 50, + quantity: 100, + purchase_limit: { + interval: PurchaseLimitInterval.Month, + interval_count: 1, + limit: 5, + count: 2, + }, + }, + ], + }, + }); + + const expanded = await getExpandedPurchaseLimit({ + autumn, + customerId, + }); + expect(expanded).toMatchObject({ + interval: PurchaseLimitInterval.Month, + interval_count: 1, + limit: 5, + count: 2, + }); + expect(expanded?.next_reset_at).toBeGreaterThan(Date.now()); + expect(expanded?.next_reset_at).not.toBe(staleWindowEndsAt); + }, +); + +test.concurrent( + `${chalk.yellowBright("customers.create: purchase_limit.count writes runtime state")}`, + async () => { + const customerId = "atu-pl-count-create"; + const { autumn } = await createClients(); + + try { + await autumn.customers.delete(customerId); + } catch { + // ignore missing + } + + await autumn.customers.create({ + id: customerId, + name: customerId, + email: `${customerId}@example.com`, + billing_controls: { + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold: 50, + quantity: 100, + purchase_limit: { + interval: PurchaseLimitInterval.Month, + limit: 5, + count: 3, + }, + }, + ], + }, + }); + + const expanded = await getExpandedPurchaseLimit({ autumn, customerId }); + expect(expanded).toMatchObject({ + interval: PurchaseLimitInterval.Month, + limit: 5, + count: 3, + }); + expect(expanded?.next_reset_at).toBeGreaterThan(Date.now()); + }, +); + +test.concurrent( + `${chalk.yellowBright("customers.update: rejected counts do not partially commit")}`, + async () => { + const customerId = "atu-pl-count-atomic"; + const { autumn } = await createClients(); + await ensureCustomer({ autumn, customerId }); + + await autumn.customers.update(customerId, { + billing_controls: { + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold: 50, + quantity: 100, + purchase_limit: { + interval: PurchaseLimitInterval.Month, + limit: 5, + count: 1, + }, + }, + ], + }, + }); + + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + func: async () => + await autumn.customers.update(customerId, { + billing_controls: { + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold: 50, + quantity: 100, + purchase_limit: { + interval: PurchaseLimitInterval.Month, + limit: 5, + count: 2, + }, + }, + { + feature_id: TestFeature.Credits, + enabled: true, + threshold: 50, + quantity: 100, + purchase_limit: { + interval: PurchaseLimitInterval.Month, + limit: 5, + count: 6, + }, + }, + ], + }, + }), + }); + + const expanded = await getExpandedPurchaseLimit({ autumn, customerId }); + expect(expanded?.count).toEqual(1); + }, +); + +test.concurrent( + `${chalk.yellowBright("customers.update: interval changes reset the purchase window")}`, + async () => { + const customerId = "atu-pl-count-interval"; + const { autumn, ctx, org } = await createClients(); + await ensureCustomer({ autumn, customerId }); + + await autumn.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 50, + quantity: 100, + purchaseLimit: { interval: PurchaseLimitInterval.Month, limit: 5 }, + }), + }); + + const customer = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: org.id, + env: ctx.env, + }); + if (!customer) throw new Error("customer missing after create"); + + const now = Date.now(); + const monthlyWindowEndsAt = now + 25 * 24 * 60 * 60 * 1000; + await autoTopupLimitRepo.insert({ + ctx, + data: { + id: generateId("atlim"), + internal_customer_id: customer.internal_id, + customer_id: customerId, + feature_id: TestFeature.Messages, + purchase_window_ends_at: monthlyWindowEndsAt, + purchase_count: 1, + attempt_window_ends_at: now, + attempt_count: 0, + failed_attempt_window_ends_at: now, + failed_attempt_count: 0, + updated_at: now, + }, + }); + + await autumn.customers.update(customerId, { + billing_controls: { + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold: 50, + quantity: 100, + purchase_limit: { + interval: PurchaseLimitInterval.Week, + limit: 5, + count: 1, + }, + }, + ], + }, + }); + + const expanded = await getExpandedPurchaseLimit({ autumn, customerId }); + expect(expanded?.next_reset_at).toBeGreaterThan( + Date.now() + 6 * 24 * 60 * 60 * 1000, + ); + expect(expanded?.next_reset_at).toBeLessThan( + Date.now() + 8 * 24 * 60 * 60 * 1000, + ); + expect(expanded?.next_reset_at).not.toEqual(monthlyWindowEndsAt); + }, +); + +test.concurrent( + `${chalk.yellowBright("customers.update: duplicate auto top-up features are rejected")}`, + async () => { + const customerId = "atu-pl-count-duplicate"; + const { autumn } = await createClients(); + await ensureCustomer({ autumn, customerId }); + + await expectAutumnError({ + func: async () => + await autumn.customers.update(customerId, { + billing_controls: { + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold: 50, + quantity: 100, + }, + { + feature_id: TestFeature.Messages, + enabled: true, + threshold: 25, + quantity: 50, + }, + ], + }, + }), + }); + }, +); diff --git a/server/tests/integration/crud/customers/list-customers-entity-license-balances.test.ts b/server/tests/integration/crud/customers/list-customers-entity-license-balances.test.ts new file mode 100644 index 0000000000..81dd639b04 --- /dev/null +++ b/server/tests/integration/crud/customers/list-customers-entity-license-balances.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, ApiVersion } from "@autumn/shared"; +import type { ApiCustomerV5 } from "@shared/api/customers/apiCustomerV5"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; + +const SEARCH = "list-entity-license-balances"; + +test.concurrent( + `${chalk.yellowBright("list-customers-entity-license-balances: entity-scoped balances pool into list, license assignments stay invisible")}`, + async () => { + const entityPro = products.pro({ + id: "lelb-entity-pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const licenseParent = products.pro({ + id: "lelb-license-parent", + items: [items.dashboard()], + }); + const seatLicense = products.base({ + id: "lelb-seat-license", + items: [items.monthlyWords({ includedUsage: 25 })], + }); + + const { customerId } = await initScenario({ + customerId: SEARCH, + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.entities({ count: 2, featureId: TestFeature.Users }), + s.products({ list: [entityPro, licenseParent, seatLicense] }), + ], + actions: [ + s.licenses.link({ + parentProductId: licenseParent.id, + licenseProductId: seatLicense.id, + included: 3, + }), + s.billing.attach({ productId: licenseParent.id }), + s.attach({ productId: entityPro.id, entityIndex: 0 }), + s.licenses.assign({ + licenseProductId: seatLicense.id, + entityIndex: 1, + }), + ], + }); + + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + + // V2.3 cursor list — balances shape + const v23 = (await autumnV2_3.customers.listV2({ + start_cursor: "", + limit: 25, + search: SEARCH, + keepInternalFields: true, + })) as { list: ApiCustomerV5[] }; + + const v23Customer = v23.list.find((c) => c.id === customerId); + expect(v23Customer).toBeDefined(); + + // Entity-scoped product balances pool up to the customer-level list view + const messages = v23Customer!.balances[TestFeature.Messages]; + expect(messages).toBeDefined(); + expect(messages!.granted).toBe(100); + + // License assignment entitlements stay invisible at the customer level — + // the pool parent reports them, the seat must not + expect(v23Customer!.balances[TestFeature.Words]).toBeUndefined(); + + // Customer-level boolean from the license parent still surfaces as a flag + expect(v23Customer!.flags[TestFeature.Dashboard]).toBeDefined(); + + // V1.2 on POST /customers/list — same invariants through the features + // transform, on the exact route+version older sync integrations hit + const v12 = (await autumnV1.customers.listV2({ + limit: 25, + offset: 0, + search: SEARCH, + keepInternalFields: true, + })) as { list: ApiCustomerV3[] }; + + const v12Customer = v12.list.find((c) => c.id === customerId); + expect(v12Customer).toBeDefined(); + expect(v12Customer!.features[TestFeature.Messages]).toBeDefined(); + expect(v12Customer!.features[TestFeature.Words]).toBeUndefined(); + }, +); diff --git a/server/tests/integration/crud/plans/create-plan-basic.test.ts b/server/tests/integration/crud/plans/create-plan-basic.test.ts index 2859a8c7cc..eeffa155bf 100644 --- a/server/tests/integration/crud/plans/create-plan-basic.test.ts +++ b/server/tests/integration/crud/plans/create-plan-basic.test.ts @@ -32,9 +32,7 @@ test.concurrent( const productId = "min_plan"; try { await autumnV2.products.delete(productId); - } catch (_error) { - console.log(_error); - } + } catch (_error) {} const created = await autumnV2.products.create< ApiPlan, @@ -355,7 +353,7 @@ test.concurrent( billing_method: BillingMethod.UsageBased, billing_units: 1000, tiers: [ - { to: 100000, amount: 10 }, + { to: 200000, amount: 10 }, { to: 500000, amount: 50 }, { to: 1000000, amount: 40 }, { to: TierInfinite, amount: 30 }, @@ -371,10 +369,6 @@ test.concurrent( billing_method: BillingMethod.UsageBased, billing_units: 1, }, - proration: { - on_increase: OnIncrease.ProrateImmediately, - on_decrease: OnDecrease.Prorate, - }, }, ], free_trial: { @@ -446,6 +440,7 @@ test.concurrent( (i) => i.feature_id === TestFeature.Messages, ); expect(meteredItem!.tiers).toHaveLength(4); + // V1.2 items expose tier bounds relative to included (200000 - 100000). expect(meteredItem!.tiers![0]).toMatchObject({ to: 100000, amount: 10 }); const seatsItem = v1_2.items.find( diff --git a/server/tests/integration/crud/plans/create/pooled-item-validation.test.ts b/server/tests/integration/crud/plans/create/pooled-item-validation.test.ts new file mode 100644 index 0000000000..dd2b887f4d --- /dev/null +++ b/server/tests/integration/crud/plans/create/pooled-item-validation.test.ts @@ -0,0 +1,70 @@ +import { test } from "bun:test"; +import { + ApiVersion, + type CreatePlanParamsV2Input, + ErrCode, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; + +const autumnRpc = new AutumnRpcCli({ version: ApiVersion.V2_1 }); + +const expectPooledItemRejected = async ({ + planId, + item, + errMessage = "Pooled items are only supported for finite metered features", +}: { + planId: string; + item: NonNullable[number]; + errMessage?: string; +}) => { + await expectAutumnError({ + errCode: ErrCode.InvalidProductItem, + errMessage, + func: () => + autumnRpc.plans.create({ + plan_id: planId, + name: planId, + group: `group-${planId}`, + auto_enable: false, + items: [item], + }), + }); +}; + +test.concurrent( + "pooled item validation: rejects boolean features", + async () => { + await expectPooledItemRejected({ + planId: `pooled-boolean-${crypto.randomUUID()}`, + item: { ...itemsV2.dashboard(), pooled: true }, + }); + }, +); + +test.concurrent( + "pooled item validation: rejects unlimited metered features", + async () => { + await expectPooledItemRejected({ + planId: `pooled-unlimited-${crypto.randomUUID()}`, + item: { + feature_id: TestFeature.Messages, + unlimited: true, + pooled: true, + }, + }); + }, +); + +test.concurrent( + "pooled item validation: rejects pay-per-use feature pricing", + async () => { + await expectPooledItemRejected({ + planId: `pooled-pay-per-use-${crypto.randomUUID()}`, + item: { ...itemsV2.consumableMessages(), pooled: true }, + errMessage: "Pooled items cannot use usage-based pricing", + }); + }, +); diff --git a/server/tests/integration/crud/plans/variants/interval-family.test.ts b/server/tests/integration/crud/plans/variants/interval-family.test.ts index 140b4d6c26..707218d9e0 100644 --- a/server/tests/integration/crud/plans/variants/interval-family.test.ts +++ b/server/tests/integration/crud/plans/variants/interval-family.test.ts @@ -21,7 +21,9 @@ import { type ApiPlanV1, ApiVersion, BillingInterval, + type FullProduct, ResetInterval, + resetIntvToEntIntv, type UpdatePlanParamsV2Input, } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features"; @@ -31,11 +33,11 @@ import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; import { ProductService } from "@/internal/products/ProductService.js"; +import { expectPreviewVariantsCorrect } from "./utils/expectVariantPreviewCorrect.js"; import { expectStripeResourcesCarriedToVariant, expectVariantProductCorrect, } from "./utils/expectVariantProductCorrect.js"; -import { expectPreviewVariantsCorrect } from "./utils/expectVariantPreviewCorrect.js"; import { readableVariantTestId } from "./utils/readableVariantTestId.js"; import { createVariantPlan } from "./utils/variantTestPlanUtils.js"; @@ -181,22 +183,28 @@ const updateVariantInterval = async ( }; const getMsgAllowance = (full: any) => - full.entitlements.find( - (e: any) => e.feature_id === TestFeature.Messages, - )?.allowance; + full.entitlements.find((e: any) => e.feature_id === TestFeature.Messages) + ?.allowance; // Different intervals don't merge: a variant can carry more than one Messages // entitlement (one per interval) once the base propagates a new-interval item // alongside an existing one, so lookups here must disambiguate by interval. -const getMsgAllowanceForInterval = (full: any, interval: ResetInterval) => - full.entitlements.find( - (e: any) => e.feature_id === TestFeature.Messages && e.interval === interval, +const getMsgAllowanceForInterval = ( + full: FullProduct, + interval: ResetInterval, +) => { + // One-off resets persist as lifetime entitlements. + const entInterval = resetIntvToEntIntv({ resetIntv: interval }); + return full.entitlements.find( + (entitlement) => + entitlement.feature_id === TestFeature.Messages && + entitlement.interval === entInterval, )?.allowance; +}; const getUsersAllowance = (full: any) => - full.entitlements.find( - (e: any) => e.feature_id === TestFeature.Users, - )?.allowance; + full.entitlements.find((e: any) => e.feature_id === TestFeature.Users) + ?.allowance; const getStripeProductId = (full: any) => full.prices.find((p: any) => p.config?.type === "fixed")?.config @@ -213,10 +221,7 @@ test.concurrent( `${chalk.yellowBright("interval-family create: 5 variants — all get base_internal_product_id, version=1, share stripe_product_id")}`, async () => { const cid = readableVariantTestId("if_create_family"); - const { ctx, rpc, baseId } = await setupBase( - cid, - `iv_base_${cid}`, - ); + const { ctx, rpc, baseId } = await setupBase(cid, `iv_base_${cid}`); const variantIds = await create5Variants(rpc, baseId, cid); const baseFull = await getFull(ctx, baseId); @@ -322,7 +327,7 @@ test.concurrent( // 5. base disable_version cascades: no targeted variant versions, // all patch in place regardless of their own customer status // ═════════════════════════════════════════════════════════════════ - test.concurrent( +test.concurrent( `${chalk.yellowBright("interval-family propagate: base disable_version cascades — all 5 variants patch in place, none version")}`, async () => { const cid = readableVariantTestId("if_variant_customer"); @@ -522,12 +527,7 @@ test.concurrent( const variantIds = await create5Variants(rpc, baseId, cid); const err = await catchErr(() => - createVariant( - rpc, - variantIds[0], - `iv_nested_${cid}`, - "Nested", - ), + createVariant(rpc, variantIds[0], `iv_nested_${cid}`, "Nested"), ); expect(err).not.toBeNull(); @@ -538,7 +538,7 @@ test.concurrent( // ═════════════════════════════════════════════════════════════════ // 11. Stripe carry-forward — variant v2 retains stripe_price_id from v1 // ═════════════════════════════════════════════════════════════════ - test.concurrent( +test.concurrent( `${chalk.yellowBright("interval-family stripe: carry-forward — variant v2 retains stripe_price_id from v1 for unchanged prices")}`, async () => { const cid = readableVariantTestId("if_stripe_price"); diff --git a/server/tests/integration/crud/plans/variants/lifecycle.test.ts b/server/tests/integration/crud/plans/variants/lifecycle.test.ts index 0676f2bdd6..ef5ddf97f8 100644 --- a/server/tests/integration/crud/plans/variants/lifecycle.test.ts +++ b/server/tests/integration/crud/plans/variants/lifecycle.test.ts @@ -21,7 +21,6 @@ */ import { expect, test } from "bun:test"; -import { expectPreviewUpdatePlanCorrect } from "../previewUpdate/utils/expectPreviewUpdatePlanCorrect.js"; import { type ApiPlanV1, ApiVersion, @@ -36,6 +35,7 @@ import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; import { ProductService } from "@/internal/products/ProductService.js"; +import { expectPreviewUpdatePlanCorrect } from "../previewUpdate/utils/expectPreviewUpdatePlanCorrect.js"; import { expectEntitlementAllowanceMatches, expectStripeResourcesCarriedToVariant, @@ -300,12 +300,14 @@ test.concurrent( ); // ─────────────────────────────────────────────────────────────────── -// 6. preview_update rejects on variant +// 6. preview_update supported on variant +// (the cannot_preview_on_variant guard was removed when variant +// previews shipped — previews on a variant now resolve normally) // ─────────────────────────────────────────────────────────────────── test.concurrent( - `${chalk.yellowBright("variants preview_update: rejects on variant → 400 cannot_preview_on_variant")}`, + `${chalk.yellowBright("variants preview_update: supported on variant, previews its own changes")}`, async () => { - const cid = readableVariantTestId("lc_preview_variant_err"); + const cid = readableVariantTestId("lc_preview_variant_ok"); const base = baseProduct(`lc_base_${cid}`); const { ctx } = await initScenario({ @@ -326,15 +328,13 @@ test.concurrent( name: "Variant", }); - const err = await catchErr(() => - rpc.post("/plans.preview_update", { - plan_id: variantId, - items: [monthlyItem(200)], - }), - ); + const preview = (await rpc.post("/plans.preview_update", { + plan_id: variantId, + items: [monthlyItem(200)], + })) as { versionable: boolean }; - expect(err).not.toBeNull(); - expect(err?.code).toBe("cannot_preview_on_variant"); + // Customer-less variant patches in place. + expect(preview.versionable).toBe(false); }, ); @@ -445,7 +445,10 @@ test.concurrent( const { autumnV2_2, ctx } = await initScenario({ customerId: cid, - setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [base] })], + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], actions: [], }); diff --git a/server/tests/integration/crud/plans/variants/reset-tier-ladder.test.ts b/server/tests/integration/crud/plans/variants/reset-tier-ladder.test.ts index b705fdd29e..6544a87e5c 100644 --- a/server/tests/integration/crud/plans/variants/reset-tier-ladder.test.ts +++ b/server/tests/integration/crud/plans/variants/reset-tier-ladder.test.ts @@ -30,10 +30,10 @@ import { ResetInterval, } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; @@ -69,8 +69,14 @@ const createVariantRpc = async ( resetVariant, }); -const previewUpdateRpc = async (planId: string, updates: Record) => - autumnRpc.rpc.call({ method: "/plans.preview_update", body: { plan_id: planId, ...updates } }); +const previewUpdateRpc = async ( + planId: string, + updates: Record, +) => + autumnRpc.rpc.call({ + method: "/plans.preview_update", + body: { plan_id: planId, ...updates }, + }); const getPlanRpc = async (planId: string) => autumnRpc.plans.get(planId); @@ -94,7 +100,10 @@ const prepaidCreditsItem = (amount: number) => ({ const dashboardItem = { feature_id: TestFeature.Dashboard }; -const createBaseWithItems = async (id: string, itemDefs: Record[]) => { +const createBaseWithItems = async ( + id: string, + itemDefs: Record[], +) => { await cleanup(id); await autumnRpc.plans.create({ plan_id: id, @@ -109,404 +118,494 @@ const createBaseWithItems = async (id: string, itemDefs: Record // 1. create_variant copies BOTH same-feature_id items — day + month preserved // ═══════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("reset-tier: create_variant copies BOTH same-feature_id day+month items")}`, async () => { - const baseId = readableVariantTestId("rt_copy"); - const variantId = `${baseId}_variant`; - await createBaseWithItems(baseId, [ - creditsItem(100, ResetInterval.Month), - creditsItem(50, ResetInterval.Day), - ]); - - await createVariantRpc(baseId, variantId, "Variant Copy"); - - const variant = await getPlanRpc(variantId); - const creditsItems = variant.items.filter((i) => i.feature_id === TestFeature.Credits); - expect(creditsItems.length).toBe(2); - - const monthItem = creditsItems.find((i) => i.reset?.interval === ResetInterval.Month); - const dayItem = creditsItems.find((i) => i.reset?.interval === ResetInterval.Day); - expect(monthItem).toBeDefined(); - expect(monthItem!.included).toBe(100); - expect(dayItem).toBeDefined(); - expect(dayItem!.included).toBe(50); -}); +test.concurrent( + `${chalk.yellowBright("reset-tier: create_variant copies BOTH same-feature_id day+month items")}`, + async () => { + const baseId = readableVariantTestId("rt_copy"); + const variantId = `${baseId}_variant`; + await createBaseWithItems(baseId, [ + creditsItem(100, ResetInterval.Month), + creditsItem(50, ResetInterval.Day), + ]); + + await createVariantRpc(baseId, variantId, "Variant Copy"); + + const variant = await getPlanRpc(variantId); + const creditsItems = variant.items.filter( + (i) => i.feature_id === TestFeature.Credits, + ); + expect(creditsItems.length).toBe(2); + + const monthItem = creditsItems.find( + (i) => i.reset?.interval === ResetInterval.Month, + ); + const dayItem = creditsItems.find( + (i) => i.reset?.interval === ResetInterval.Day, + ); + expect(monthItem).toBeDefined(); + expect(monthItem!.included).toBe(100); + expect(dayItem).toBeDefined(); + expect(dayItem!.included).toBe(50); + }, +); // ═══════════════════════════════════════════════════════════════════════════ // 2. preview_update mutates only day-reset item — diff has ONE remove, ONE add // ═══════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("reset-tier: preview_update diff targets only day-reset item, month sibling untouched")}`, async () => { - const baseId = readableVariantTestId("rt_diff_day_month"); - await createBaseWithItems(baseId, [ - creditsItem(100, ResetInterval.Month), - creditsItem(50, ResetInterval.Day), - ]); - - const preview = await previewUpdateRpc(baseId, { - items: [ +test.concurrent( + `${chalk.yellowBright("reset-tier: preview_update diff targets only day-reset item, month sibling untouched")}`, + async () => { + const baseId = readableVariantTestId("rt_diff_day_month"); + await createBaseWithItems(baseId, [ creditsItem(100, ResetInterval.Month), - creditsItem(200, ResetInterval.Day), - ], - }); + creditsItem(50, ResetInterval.Day), + ]); + + const preview = await previewUpdateRpc(baseId, { + items: [ + creditsItem(100, ResetInterval.Month), + creditsItem(200, ResetInterval.Day), + ], + }); - expect(preview.item_changes).toHaveLength(2); - expectPreviewItemChangeCorrect({ - preview, - action: "deleted", - featureId: TestFeature.Credits, - item: { reset: { interval: ResetInterval.Day } }, - }); - expectPreviewItemChangeCorrect({ - preview, - action: "created", - featureId: TestFeature.Credits, - item: { reset: { interval: ResetInterval.Day } }, - }); + expect(preview.item_changes).toHaveLength(2); + expectPreviewItemChangeCorrect({ + preview, + action: "deleted", + featureId: TestFeature.Credits, + item: { reset: { interval: ResetInterval.Day } }, + }); + expectPreviewItemChangeCorrect({ + preview, + action: "created", + featureId: TestFeature.Credits, + item: { reset: { interval: ResetInterval.Day } }, + }); - const removedMonth = preview.item_changes.some( - (change) => - change.action === "deleted" && - change.item.reset?.interval === ResetInterval.Month, - ); - expect(removedMonth).toBe(false); -}); + const removedMonth = preview.item_changes.some( + (change) => + change.action === "deleted" && + change.item.reset?.interval === ResetInterval.Month, + ); + expect(removedMonth).toBe(false); + }, +); // ═══════════════════════════════════════════════════════════════════════════ // 3. propagate day-only change — variant month byte-identical, day reflects new // ═══════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("reset-tier: propagate day-only change, month sibling byte-identical")}`, async () => { - const baseId = readableVariantTestId("rt_prop_day"); - const variantId = `${baseId}_variant`; - await createBaseWithItems(baseId, [ - creditsItem(100, ResetInterval.Month), - creditsItem(50, ResetInterval.Day), - ]); - await createVariantRpc(baseId, variantId, "Variant Prop"); - - const variantBefore = await getPlanRpc(variantId); - const monthBefore = variantBefore.items.find( - (i) => i.feature_id === TestFeature.Credits && i.reset?.interval === ResetInterval.Month, - ); - - await autumnRpc.plans.update(baseId, { - items: [ +test.concurrent( + `${chalk.yellowBright("reset-tier: propagate day-only change, month sibling byte-identical")}`, + async () => { + const baseId = readableVariantTestId("rt_prop_day"); + const variantId = `${baseId}_variant`; + await createBaseWithItems(baseId, [ creditsItem(100, ResetInterval.Month), - creditsItem(300, ResetInterval.Day), - ], - update_variant_ids: [variantId], - }); - - const variantAfter = await getPlanRpc(variantId); - const monthAfter = variantAfter.items.find( - (i) => i.feature_id === TestFeature.Credits && i.reset?.interval === ResetInterval.Month, - ); - const dayAfter = variantAfter.items.find( - (i) => i.feature_id === TestFeature.Credits && i.reset?.interval === ResetInterval.Day, - ); + creditsItem(50, ResetInterval.Day), + ]); + await createVariantRpc(baseId, variantId, "Variant Prop"); + + const variantBefore = await getPlanRpc(variantId); + const monthBefore = variantBefore.items.find( + (i) => + i.feature_id === TestFeature.Credits && + i.reset?.interval === ResetInterval.Month, + ); + + await autumnRpc.plans.update(baseId, { + items: [ + creditsItem(100, ResetInterval.Month), + creditsItem(300, ResetInterval.Day), + ], + update_variant_ids: [variantId], + }); - expect(monthAfter).toEqual(monthBefore); - expect(dayAfter!.included).toBe(300); -}); + const variantAfter = await getPlanRpc(variantId); + const monthAfter = variantAfter.items.find( + (i) => + i.feature_id === TestFeature.Credits && + i.reset?.interval === ResetInterval.Month, + ); + const dayAfter = variantAfter.items.find( + (i) => + i.feature_id === TestFeature.Credits && + i.reset?.interval === ResetInterval.Day, + ); + + expect(monthAfter).toEqual(monthBefore); + expect(dayAfter!.included).toBe(300); + }, +); // ═══════════════════════════════════════════════════════════════════════════ // 4. credit-pack tier ladder — 4 priced variants, Dashboard propagated to all // ═══════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("reset-tier: tier ladder — 4 priced variants retain own price, Dashboard added")}`, async () => { - const baseId = readableVariantTestId("rt_ladder"); - await createBaseWithItems(baseId, [prepaidCreditsItem(10)]); - - const variantIds: string[] = []; - const prices = [10, 20, 30, 40]; - for (let i = 0; i < 4; i++) { - const vid = `${baseId}_tier_${i + 1}`; - await createVariantRpc(baseId, vid, `Tier ${i + 1}`); - await autumnRpc.plans.update(vid, { - items: [prepaidCreditsItem(prices[i])], - }); - variantIds.push(vid); - } +test.concurrent( + `${chalk.yellowBright("reset-tier: tier ladder — 4 priced variants retain own price, Dashboard added")}`, + async () => { + const baseId = readableVariantTestId("rt_ladder"); + await createBaseWithItems(baseId, [prepaidCreditsItem(10)]); + + const variantIds: string[] = []; + const prices = [10, 20, 30, 40]; + for (let i = 0; i < 4; i++) { + const vid = `${baseId}_tier_${i + 1}`; + await createVariantRpc(baseId, vid, `Tier ${i + 1}`); + await autumnRpc.plans.update(vid, { + items: [prepaidCreditsItem(prices[i])], + }); + variantIds.push(vid); + } - await autumnRpc.plans.update(baseId, { - items: [prepaidCreditsItem(10), dashboardItem], - update_variant_ids: variantIds, - }); + await autumnRpc.plans.update(baseId, { + items: [prepaidCreditsItem(10), dashboardItem], + update_variant_ids: variantIds, + }); - for (let i = 0; i < 4; i++) { - const variant = await getPlanRpc(variantIds[i]); - const credits = variant.items.find((it) => it.feature_id === TestFeature.Credits); - const dashboard = variant.items.find((it) => it.feature_id === TestFeature.Dashboard); + for (let i = 0; i < 4; i++) { + const variant = await getPlanRpc(variantIds[i]); + const credits = variant.items.find( + (it) => it.feature_id === TestFeature.Credits, + ); + const dashboard = variant.items.find( + (it) => it.feature_id === TestFeature.Dashboard, + ); - expect(dashboard).toBeDefined(); - expect(credits?.price?.amount).toBe(prices[i]); - } + expect(dashboard).toBeDefined(); + expect(credits?.price?.amount).toBe(prices[i]); + } - await cleanup(baseId, ...variantIds); -}); + await cleanup(baseId, ...variantIds); + }, +); // ═══════════════════════════════════════════════════════════════════════════ // 5. 21 variants → too_many_variants (400) // ═══════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("reset-tier: 21 variants in propagate → too_many_variants")}`, async () => { - const baseId = readableVariantTestId("rt_limit_21"); - await createBaseWithItems(baseId, [creditsItem(100, ResetInterval.Month)]); - - const variantIds: string[] = []; - for (let i = 0; i < 21; i++) { - const vid = `${baseId}_variant_${i}`; - await createVariantRpc(baseId, vid, `V${i}`); - variantIds.push(vid); - } - - await expectAutumnError({ - errCode: "too_many_variants", - func: async () => { - await autumnRpc.plans.update(baseId, { - items: [creditsItem(200, ResetInterval.Month)], - update_variant_ids: variantIds, - }); - }, - }); +test.concurrent( + `${chalk.yellowBright("reset-tier: 21 variants in propagate → too_many_variants")}`, + async () => { + const baseId = readableVariantTestId("rt_limit_21"); + await createBaseWithItems(baseId, [creditsItem(100, ResetInterval.Month)]); + + const variantIds: string[] = []; + for (let i = 0; i < 21; i++) { + const vid = `${baseId}_variant_${i}`; + await createVariantRpc(baseId, vid, `V${i}`); + variantIds.push(vid); + } + + await expectAutumnError({ + errCode: "too_many_variants", + func: async () => { + await autumnRpc.plans.update(baseId, { + items: [creditsItem(200, ResetInterval.Month)], + update_variant_ids: variantIds, + }); + }, + }); - await cleanup(baseId, ...variantIds); -}); + await cleanup(baseId, ...variantIds); + }, +); // ═══════════════════════════════════════════════════════════════════════════ // 6. 20 variants succeeds — boundary case // ═══════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("reset-tier: 20 variants in propagate succeeds (boundary)")}`, async () => { - const baseId = readableVariantTestId("rt_limit_20"); - await createBaseWithItems(baseId, [creditsItem(100, ResetInterval.Month)]); - - const variantIds: string[] = []; - for (let i = 0; i < 20; i++) { - const vid = `${baseId}_variant_${i}`; - await createVariantRpc(baseId, vid, `V${i}`); - variantIds.push(vid); - } - - await autumnRpc.plans.update(baseId, { - items: [creditsItem(200, ResetInterval.Month)], - update_variant_ids: variantIds, - }); +test.concurrent( + `${chalk.yellowBright("reset-tier: 20 variants in propagate succeeds (boundary)")}`, + async () => { + const baseId = readableVariantTestId("rt_limit_20"); + await createBaseWithItems(baseId, [creditsItem(100, ResetInterval.Month)]); + + const variantIds: string[] = []; + for (let i = 0; i < 20; i++) { + const vid = `${baseId}_variant_${i}`; + await createVariantRpc(baseId, vid, `V${i}`); + variantIds.push(vid); + } + + await autumnRpc.plans.update(baseId, { + items: [creditsItem(200, ResetInterval.Month)], + update_variant_ids: variantIds, + }); - for (const vid of variantIds) { - const variant = await getPlanRpc(vid); - const credits = variant.items.find((it) => it.feature_id === TestFeature.Credits); - expect(credits?.included).toBe(200); - } + for (const vid of variantIds) { + const variant = await getPlanRpc(vid); + const credits = variant.items.find( + (it) => it.feature_id === TestFeature.Credits, + ); + expect(credits?.included).toBe(200); + } - await cleanup(baseId, ...variantIds); -}); + await cleanup(baseId, ...variantIds); + }, +); // ═══════════════════════════════════════════════════════════════════════════ // 7. day-reset survives versioning of both base and variant // ═══════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("reset-tier: day-reset survives versioning of both base and variant")}`, async () => { - const customerId = readableVariantTestId("rt_version_day"); - const cust2 = readableVariantTestId("rt_version_day_c2"); - const prefixedBaseId = `base_${customerId}`; - const variantId = `${prefixedBaseId}_variant`; - await deleteVariantTestCustomers({ - client: autumnV1_2, - customerIds: [customerId, cust2], - }); - await cleanup(variantId, prefixedBaseId); - - const baseProd = products.base({ - id: "base", - items: [ - items.monthlyCredits({ includedUsage: 100 }), - { - feature_id: TestFeature.Credits, - included_usage: 50, - interval: ProductItemInterval.Day, - interval_count: 1, - } as any, - ], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ testClock: false }), - s.products({ list: [baseProd] }), - ], - actions: [s.attach({ productId: "base" })], - }); - - await createVariantRpc(prefixedBaseId, variantId, "Variant Versioned"); - - await autumnV1_2.createCustomer({ id: cust2, email: `${cust2}@test.com`, name: "C2" }); - await autumnV1_2.attach({ customer_id: cust2, product_id: variantId } as any); - - await autumnRpc.plans.update(prefixedBaseId, { - items: [ - creditsItem(100, ResetInterval.Month), - creditsItem(300, ResetInterval.Day), - ], - update_variant_ids: [variantId], - }); +test.concurrent( + `${chalk.yellowBright("reset-tier: day-reset survives versioning of both base and variant")}`, + async () => { + const customerId = readableVariantTestId("rt_version_day"); + const cust2 = readableVariantTestId("rt_version_day_c2"); + const prefixedBaseId = `base_${customerId}`; + const variantId = `${prefixedBaseId}_variant`; + await deleteVariantTestCustomers({ + client: autumnV1_2, + customerIds: [customerId, cust2], + }); + await cleanup(variantId, prefixedBaseId); + + const baseProd = products.base({ + id: "base", + items: [ + items.monthlyCredits({ includedUsage: 100 }), + { + feature_id: TestFeature.Credits, + included_usage: 50, + interval: ProductItemInterval.Day, + interval_count: 1, + } as any, + ], + }); - const baseAfter = await ProductService.getFull({ db, idOrInternalId: prefixedBaseId, orgId: org.id, env }); - const variantAfter = await ProductService.getFull({ db, idOrInternalId: variantId, orgId: org.id, env }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [baseProd] }), + ], + actions: [s.attach({ productId: "base" })], + }); - expect(baseAfter.version).toBe(2); - expect(variantAfter.version).toBe(2); + await createVariantRpc(prefixedBaseId, variantId, "Variant Versioned"); - const basePlan = await getPlanRpc(prefixedBaseId); - const variantPlan = await getPlanRpc(variantId); + await autumnV1_2.createCustomer({ + id: cust2, + email: `${cust2}@test.com`, + name: "C2", + }); + await autumnV1_2.attach({ + customer_id: cust2, + product_id: variantId, + } as any); + + await autumnRpc.plans.update(prefixedBaseId, { + items: [ + creditsItem(100, ResetInterval.Month), + creditsItem(300, ResetInterval.Day), + ], + update_variant_ids: [variantId], + }); - for (const plan of [basePlan, variantPlan]) { - const creditsItems = plan.items.filter((i) => i.feature_id === TestFeature.Credits); - expect(creditsItems.length).toBe(2); - const dayItem = creditsItems.find((i) => i.reset?.interval === ResetInterval.Day); - const monthItem = creditsItems.find((i) => i.reset?.interval === ResetInterval.Month); - expect(dayItem).toBeDefined(); - expect(dayItem!.included).toBe(300); - expect(monthItem).toBeDefined(); - expect(monthItem!.included).toBe(100); - } + const baseAfter = await ProductService.getFull({ + db, + idOrInternalId: prefixedBaseId, + orgId: org.id, + env, + }); + const variantAfter = await ProductService.getFull({ + db, + idOrInternalId: variantId, + orgId: org.id, + env, + }); - await cleanup(prefixedBaseId, variantId); -}); + expect(baseAfter.version).toBe(2); + expect(variantAfter.version).toBe(2); + + const basePlan = await getPlanRpc(prefixedBaseId); + const variantPlan = await getPlanRpc(variantId); + + for (const plan of [basePlan, variantPlan]) { + const creditsItems = plan.items.filter( + (i) => i.feature_id === TestFeature.Credits, + ); + expect(creditsItems.length).toBe(2); + const dayItem = creditsItems.find( + (i) => i.reset?.interval === ResetInterval.Day, + ); + const monthItem = creditsItems.find( + (i) => i.reset?.interval === ResetInterval.Month, + ); + expect(dayItem).toBeDefined(); + expect(dayItem!.included).toBe(300); + expect(monthItem).toBeDefined(); + expect(monthItem!.included).toBe(100); + } + + await cleanup(prefixedBaseId, variantId); + }, +); // ═══════════════════════════════════════════════════════════════════════════ // 8. hour-reset propagates — filter uses interval: "hour" // ═══════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("reset-tier: hour-reset propagates to variant")}`, async () => { - const baseId = readableVariantTestId("rt_hour"); - const variantId = `${baseId}_variant`; - await createBaseWithItems(baseId, [ - creditsItem(50, ResetInterval.Hour), - ]); +test.concurrent( + `${chalk.yellowBright("reset-tier: hour-reset propagates to variant")}`, + async () => { + const baseId = readableVariantTestId("rt_hour"); + const variantId = `${baseId}_variant`; + await createBaseWithItems(baseId, [creditsItem(50, ResetInterval.Hour)]); - const preview = await previewUpdateRpc(baseId, { - items: [creditsItem(200, ResetInterval.Hour)], - }); + const preview = await previewUpdateRpc(baseId, { + items: [creditsItem(200, ResetInterval.Hour)], + }); - expectPreviewItemChangeCorrect({ - preview, - action: "deleted", - featureId: TestFeature.Credits, - item: { reset: { interval: ResetInterval.Hour } }, - }); + expectPreviewItemChangeCorrect({ + preview, + action: "deleted", + featureId: TestFeature.Credits, + item: { reset: { interval: ResetInterval.Hour } }, + }); - await createVariantRpc(baseId, variantId, "Variant Hour"); + await createVariantRpc(baseId, variantId, "Variant Hour"); - await autumnRpc.plans.update(baseId, { - items: [creditsItem(200, ResetInterval.Hour)], - update_variant_ids: [variantId], - }); + await autumnRpc.plans.update(baseId, { + items: [creditsItem(200, ResetInterval.Hour)], + update_variant_ids: [variantId], + }); - const variant = await getPlanRpc(variantId); - const hourItem = variant.items.find( - (i) => i.feature_id === TestFeature.Credits && i.reset?.interval === ResetInterval.Hour, - ); - expect(hourItem).toBeDefined(); - expect(hourItem!.included).toBe(200); + const variant = await getPlanRpc(variantId); + const hourItem = variant.items.find( + (i) => + i.feature_id === TestFeature.Credits && + i.reset?.interval === ResetInterval.Hour, + ); + expect(hourItem).toBeDefined(); + expect(hourItem!.included).toBe(200); - await cleanup(baseId, variantId); -}); + await cleanup(baseId, variantId); + }, +); // ═══════════════════════════════════════════════════════════════════════════ // 9. Stripe price reuse on tier-ladder version-up — variant v2 retains stripe_price_id // ═══════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("reset-tier: Stripe price_id retained on variant version-up")}`, async () => { - const baseId = readableVariantTestId("rt_stripe_price"); - const variantId = `${baseId}_variant`; - await createBaseWithItems(baseId, [prepaidCreditsItem(10)]); - await createVariantRpc(baseId, variantId, "Variant Stripe"); - - const v1 = await ProductService.getFull({ db, idOrInternalId: variantId, orgId: org.id, env }); - const v1Price = v1.prices.find( - (p: any) => p.config?.feature_id === TestFeature.Credits && p.config?.stripe_price_id, - ); - expect(v1Price).toBeDefined(); - const v1StripeId = (v1Price as any)?.config?.stripe_price_id; - expect(v1StripeId).toBeTruthy(); - - await autumnRpc.plans.update(baseId, { - items: [prepaidCreditsItem(10), dashboardItem], - update_variant_ids: [variantId], - force_version: true, - } as any); - - const v2 = await ProductService.getFull({ db, idOrInternalId: variantId, orgId: org.id, env }); - expect(v2.version).toBe(2); - - const v2Price = v2.prices.find( - (p: any) => p.config?.feature_id === TestFeature.Credits && p.config?.stripe_price_id, - ); - expect(v2Price).toBeDefined(); - expect((v2Price as any)?.config?.stripe_price_id).toBe(v1StripeId); - - await cleanup(baseId, variantId); -}); +test.concurrent( + `${chalk.yellowBright("reset-tier: Stripe price_id retained on variant version-up")}`, + async () => { + const baseId = readableVariantTestId("rt_stripe_price"); + const variantId = `${baseId}_variant`; + await createBaseWithItems(baseId, [prepaidCreditsItem(10)]); + await createVariantRpc(baseId, variantId, "Variant Stripe"); + + const v1 = await ProductService.getFull({ + db, + idOrInternalId: variantId, + orgId: org.id, + env, + }); + const v1Price = v1.prices.find( + (p: any) => + p.config?.feature_id === TestFeature.Credits && + p.config?.stripe_price_id, + ); + expect(v1Price).toBeDefined(); + const v1StripeId = (v1Price as any)?.config?.stripe_price_id; + expect(v1StripeId).toBeTruthy(); + + await autumnRpc.plans.update(baseId, { + items: [prepaidCreditsItem(10), dashboardItem], + update_variant_ids: [variantId], + force_version: true, + } as any); + + const v2 = await ProductService.getFull({ + db, + idOrInternalId: variantId, + orgId: org.id, + env, + }); + expect(v2.version).toBe(2); + + const v2Price = v2.prices.find( + (p: any) => + p.config?.feature_id === TestFeature.Credits && + p.config?.stripe_price_id, + ); + expect(v2Price).toBeDefined(); + expect((v2Price as any)?.config?.stripe_price_id).toBe(v1StripeId); + + await cleanup(baseId, variantId); + }, +); // ═══════════════════════════════════════════════════════════════════════════ // 10. create_variant rejects archived source — 400 cannot_fork_archived_base // ═══════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("reset-tier: create_variant rejects archived base → cannot_fork_archived_base")}`, async () => { - const baseId = readableVariantTestId("rt_archived_err"); - const variantId = `${baseId}_variant`; - await createBaseWithItems(baseId, [creditsItem(100, ResetInterval.Month)]); +test.concurrent( + `${chalk.yellowBright("reset-tier: create_variant rejects archived base → cannot_fork_archived_base")}`, + async () => { + const baseId = readableVariantTestId("rt_archived_err"); + const variantId = `${baseId}_variant`; + await createBaseWithItems(baseId, [creditsItem(100, ResetInterval.Month)]); - await autumnRpc.plans.update(baseId, { archived: true }); + await autumnRpc.plans.update(baseId, { archived: true }); - await expectAutumnError({ - errCode: "cannot_fork_archived_base", - func: async () => { - await createVariantRpc(baseId, variantId, "Variant Archived"); - }, - }); + await expectAutumnError({ + errCode: "cannot_fork_archived_base", + func: async () => { + await createVariantRpc(baseId, variantId, "Variant Archived"); + }, + }); - await cleanup(baseId); -}); + await cleanup(baseId); + }, +); // ═══════════════════════════════════════════════════════════════════════════ // 11. create_variant rejects id collision — 409 product_id_already_exists // ═══════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("reset-tier: create_variant rejects id collision → product_id_already_exists")}`, async () => { - const baseId = readableVariantTestId("rt_collision_err"); - await createBaseWithItems(baseId, [creditsItem(100, ResetInterval.Month)]); - - await expectAutumnError({ - errCode: "product_id_already_exists", - func: async () => { - await createVariantRpc(baseId, baseId, "Self Collision", false); - }, - }); +test.concurrent( + `${chalk.yellowBright("reset-tier: create_variant rejects id collision → product_id_already_exists")}`, + async () => { + const baseId = readableVariantTestId("rt_collision_err"); + await createBaseWithItems(baseId, [creditsItem(100, ResetInterval.Month)]); + + await expectAutumnError({ + errCode: "product_id_already_exists", + func: async () => { + await createVariantRpc(baseId, baseId, "Self Collision", false); + }, + }); - await cleanup(baseId); -}); + await cleanup(baseId); + }, +); // ═══════════════════════════════════════════════════════════════════════════ -// 12. preview_update rejects variant id — 400 cannot_preview_on_variant +// 12. preview_update supported on variant id +// (the cannot_preview_on_variant guard was removed when variant previews +// shipped — previews on a variant now resolve normally) // ═══════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("reset-tier: preview_update rejects variant id → cannot_preview_on_variant")}`, async () => { - const baseId = readableVariantTestId("rt_preview_variant_err"); - const variantId = `${baseId}_variant`; - await createBaseWithItems(baseId, [creditsItem(100, ResetInterval.Month)]); - await createVariantRpc(baseId, variantId, "Variant Preview"); - - await expectAutumnError({ - errCode: "cannot_preview_on_variant", - func: async () => { - await previewUpdateRpc(variantId, { - items: [creditsItem(200, ResetInterval.Month)], - }); - }, - }); +test.concurrent( + `${chalk.yellowBright("reset-tier: preview_update on variant id previews its own changes")}`, + async () => { + const baseId = readableVariantTestId("rt_preview_variant_ok"); + const variantId = `${baseId}_variant`; + await createBaseWithItems(baseId, [creditsItem(100, ResetInterval.Month)]); + await createVariantRpc(baseId, variantId, "Variant Preview"); + + // Customer-less variant patches in place. + const preview = await previewUpdateRpc(variantId, { + items: [creditsItem(200, ResetInterval.Month)], + }); + expect(preview.versionable).toBe(false); - await cleanup(baseId, variantId); -}); + await cleanup(baseId, variantId); + }, +); diff --git a/server/tests/integration/crud/plans/versioning/variant-independent-versioning.test.ts b/server/tests/integration/crud/plans/versioning/variant-independent-versioning.test.ts index 98f8b601a2..5ae9e5370c 100644 --- a/server/tests/integration/crud/plans/versioning/variant-independent-versioning.test.ts +++ b/server/tests/integration/crud/plans/versioning/variant-independent-versioning.test.ts @@ -37,6 +37,8 @@ type VariantSetup = { attachCustomer?: boolean; }; +const runSuffix = crypto.randomUUID().slice(0, 8); + const monthlyMessagesItem = (included: number) => ({ feature_id: TestFeature.Messages, included, @@ -116,13 +118,15 @@ const getOptionalProduct = (params: { const setupVariantVersioning = async ({ testId, attachBaseCustomer = false, + isolatedOrg = false, variants, }: { testId: string; attachBaseCustomer?: boolean; + isolatedOrg?: boolean; variants: VariantSetup[]; }) => { - const customerId = `variant_versioning_${testId}`; + const customerId = `vv_${testId}_${runSuffix}`; const variantCustomerIds = variants .filter((variant) => variant.attachCustomer) .map((variant) => `${customerId}_${variant.key}`); @@ -134,6 +138,9 @@ const setupVariantVersioning = async ({ const { autumnV2_2, ctx } = await initScenario({ customerId, setup: [ + ...(isolatedOrg + ? [s.platform.create({ setupDefaultFeatures: true })] + : []), s.customer({}), s.products({ list: [base] }), ...(variantCustomerIds.length > 0 @@ -752,6 +759,7 @@ test.concurrent( const { autumnV2_2, baseId, baseV1, ctx, rpc, variantIds } = await setupVariantVersioning({ testId: "catalog_delete_latest_base_relink", + isolatedOrg: true, variants: [{ key: "annual" }], }); const variantId = variantIds.annual; diff --git a/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts b/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts index 9dc2046d0b..dad489178b 100644 --- a/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts +++ b/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts @@ -361,6 +361,7 @@ const buildCustomerEntitlement = ({ feature_id: entitlement.feature_id ?? null, external_id: `bal_${key}_${suffix}`, expired: null, + reset_by_invoice: null, }); const buildRollover = ({ diff --git a/server/tests/integration/external-psps/revenuecat/revenuecat-auto-topup-stripe.test.ts b/server/tests/integration/external-psps/revenuecat/revenuecat-auto-topup-stripe.test.ts new file mode 100644 index 0000000000..34e7d11b12 --- /dev/null +++ b/server/tests/integration/external-psps/revenuecat/revenuecat-auto-topup-stripe.test.ts @@ -0,0 +1,236 @@ +/** + * RevenueCat subscription + Stripe-billed auto top-up. + * + * A customer with a Stripe customer + card on file purchases a subscription + * (monthly base price + one-off prepaid messages item) through RevenueCat. + * When the prepaid balance drops below the auto top-up threshold, the top-up + * should bill through Stripe even though the cus_product is RC-provisioned. + * + * Probes: + * - RC attach (no_billing_changes) creates the cusPrice for the one-off + * prepaid item, so fullCustomerToAutoTopupObjects can find it. + * - Auto top-up executes a paid Stripe invoice for an RC cus_product whose + * prices were never provisioned in Stripe. + * - The options.quantity bump lands on the RC cus_product. + * - A subsequent RC RENEWAL does not clobber the topped-up state. + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + AppEnv, + CusProductStatus, + customers, + ProcessorType, +} from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { expectCustomerProductOptions } from "@tests/integration/utils/expectCustomerProductOptions"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { timeout } from "@tests/utils/genUtils"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; +import { OrgService } from "@/internal/orgs/OrgService"; +import { encryptData } from "@/utils/encryptUtils"; +import { + expectWebhookSuccess, + RevenueCatWebhookClient, +} from "./utils/revenue-cat-webhook-client"; + +const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_auto_topup"; + +/** Wait time for SQS auto top-up processing */ +const AUTO_TOPUP_WAIT_MS = 20000; + +const setupRevenueCatOrg = async () => { + if ( + ctx.org.processor_configs?.revenuecat?.sandbox_webhook_secret !== + RC_WEBHOOK_SECRET + ) { + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { + processor_configs: { + ...ctx.org.processor_configs, + revenuecat: { + api_key: encryptData("mock_rc_api_key_live"), + sandbox_api_key: encryptData("mock_rc_api_key_sandbox"), + project_id: "mock_project_live", + sandbox_project_id: "mock_project_sandbox", + webhook_secret: RC_WEBHOOK_SECRET, + sandbox_webhook_secret: RC_WEBHOOK_SECRET, + }, + }, + }, + }); + } +}; + +test.concurrent( + `${chalk.yellowBright("revenuecat auto-topup: RC subscription tops up prepaid balance through Stripe")}`, + async () => { + const customerId = "rc-auto-topup-stripe-1"; + const RC_SUB_SKU = "com.app.rc_auto_topup_sub_monthly"; + + // $20/month subscription carrying a one-off prepaid item ($10 / 100 messages) + const sub = products.pro({ + id: "rc-topup-sub", + items: [ + items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }), + ], + }); + + await setupRevenueCatOrg(); + + // Stripe customer + card on file, but NO Stripe products attached — the + // cross-processor guard only rejects non-RC cus_products with subscriptions. + const { autumnV2_1, ctx: scenarioCtx } = await initScenario({ + customerId, + setup: [ + s.deleteCustomer({ customerId }), + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [sub] }), + ], + actions: [], + }); + + // Map the RC SKU to the sub, granting 500 messages (5 packs) on purchase. + await RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: AppEnv.Sandbox, + autumn_product_id: sub.id, + revenuecat_product_ids: [RC_SUB_SKU], + feature_quantities: { + [RC_SUB_SKU]: [{ feature_id: TestFeature.Messages, quantity: 500 }], + }, + }, + }); + + const rcClient = new RevenueCatWebhookClient({ + orgId: ctx.org.id, + env: ctx.env, + webhookSecret: RC_WEBHOOK_SECRET, + }); + + expectWebhookSuccess( + await rcClient.initialPurchase({ + productId: RC_SUB_SKU, + appUserId: customerId, + originalTransactionId: "rc_auto_topup_tx_001", + transactionId: "rc_auto_topup_tx_001", + price: 20, + purchasedAtMs: Date.now(), + }), + ); + + // ── 1. RC cus_product is active with the mapped prepaid grant ────────── + const dbCustomer = await ctx.db.query.customers.findFirst({ + where: eq(customers.id, customerId), + }); + const cusProducts = await CusProductService.list({ + db: ctx.db, + internalCustomerId: dbCustomer!.internal_id, + inStatuses: [CusProductStatus.Active], + }); + const rcCusProduct = cusProducts.find((cp) => cp.product.id === sub.id); + expect(rcCusProduct).toBeDefined(); + expect(rcCusProduct!.processor?.type).toBe(ProcessorType.RevenueCat); + + const before = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: before, + featureId: TestFeature.Messages, + remaining: 500, + }); + + // ── 2. Enable auto top-up (threshold 20, quantity 100 = 1 pack) ──────── + await autumnV2_1.customers.update(customerId, { + billing_controls: { + auto_topups: [ + { + feature_id: TestFeature.Messages, + enabled: true, + threshold: 20, + quantity: 100, + }, + ], + }, + }); + + // ── 3. Track below threshold → auto top-up should bill via Stripe ───── + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 490, + }); + + await timeout(AUTO_TOPUP_WAIT_MS); + + // Balance: 500 - 490 + 100 = 110 + const after = await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Messages, + remaining: 110, + }); + + // Latest invoice is the Stripe top-up: 1 pack × $10, paid. + // Count 2 = RC-recorded purchase invoice + Stripe top-up invoice. + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 10, + latestStatus: "paid", + latestInvoiceProductId: sub.id, + }); + + // Options: 5 initial packs + 1 topped-up pack = 6. + await expectCustomerProductOptions({ + ctx: scenarioCtx, + customerId, + productId: sub.id, + featureId: TestFeature.Messages, + quantity: 6, + }); + + // ── 4. RC renewal must not clobber the topped-up state ───────────────── + expectWebhookSuccess( + await rcClient.renewal({ + productId: RC_SUB_SKU, + appUserId: customerId, + originalTransactionId: "rc_auto_topup_tx_001", + transactionId: "rc_auto_topup_tx_002", + price: 20, + purchasedAtMs: Date.now(), + }), + ); + + const afterRenewal = + await autumnV2_1.customers.get(customerId); + expectBalanceCorrect({ + customer: afterRenewal, + featureId: TestFeature.Messages, + remaining: 110, + }); + await expectCustomerProductOptions({ + ctx: scenarioCtx, + customerId, + productId: sub.id, + featureId: TestFeature.Messages, + quantity: 6, + }); + }, +); diff --git a/server/tests/integration/invoices/invoices-insert.test.ts b/server/tests/integration/invoices/invoices-insert.test.ts new file mode 100644 index 0000000000..afac45c597 --- /dev/null +++ b/server/tests/integration/invoices/invoices-insert.test.ts @@ -0,0 +1,329 @@ +/** + * TDD test for POST /v1/invoices.insert — batched legacy invoice upserts. + * + * Contract under test: + * New types/fields: + * - InsertInvoicesParams: { invoices: InsertInvoiceParams[] } with max 500 + * - entity_id is accepted by the server but hidden from public docs/output + * New endpoint: + * - POST /v1/invoices.insert -> { invoices: ApiInsertedInvoice[] } + * New behaviors: + * - response order matches request order + * - customer_id, entity_id, and latest plan versions resolve in batched queries + * - currency defaults to org.default_currency + * - hosted_invoice_url is optional, persisted, and echoed directly + * - existing stripe_id rows are fully updated while retaining their Autumn IDs + * - all affected customer caches are invalidated; no invoice cache write occurs + * - invalid customer/entity/plan references reject the batch before any writes + * Side effects: + * - invoices are upserted in Postgres only + * - affected customer caches are cleared after the upsert + * - no processor resources are read or mutated + * + * Pre-implementation red: the route and request/response schemas do not exist. + * Post-implementation green: every batch, resolution, upsert, and validation assertion passes. + */ + +import { expect, test } from "bun:test"; +import { CustomerExpand, invoices, products } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products as productFixtures } from "@tests/utils/fixtures/products"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { and, eq } from "drizzle-orm"; + +test.concurrent( + `${chalk.yellowBright("invoices.insert: batch upserts legacy invoices in request order")}`, + async () => { + const customerId = "invoice-batch-a"; + const otherCustomerId = "invoice-batch-b"; + const runTag = Date.now().toString(); + const firstStripeId = `in_legacy_first_${runTag}`; + const secondStripeId = `in_legacy_second_${runTag}`; + const originalCreatedAt = Date.UTC(2016, 0, 1); + + const pro = productFixtures.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const free = productFixtures.base({ id: "free", items: [] }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [pro, free] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + s.otherCustomers([{ id: otherCustomerId }]), + ], + actions: [], + }); + + const customerRows = await ctx.db.query.customers.findMany({ + where: (customer, { and: andOp, eq: eqOp, inArray }) => + andOp( + eqOp(customer.org_id, ctx.org.id), + eqOp(customer.env, ctx.env), + inArray(customer.id, [customerId, otherCustomerId]), + ), + }); + const customerById = new Map(customerRows.map((row) => [row.id, row])); + const primaryCustomer = customerById.get(customerId); + const otherCustomer = customerById.get(otherCustomerId); + expect(primaryCustomer).toBeDefined(); + expect(otherCustomer).toBeDefined(); + + const entity = await ctx.db.query.entities.findFirst({ + where: (entity, { and: andOp, eq: eqOp }) => + andOp( + eqOp(entity.internal_customer_id, primaryCustomer!.internal_id), + eqOp(entity.id, "ent-1"), + ), + }); + expect(entity).toBeDefined(); + + const proV1 = await ctx.db.query.products.findFirst({ + where: and( + eq(products.org_id, ctx.org.id), + eq(products.env, ctx.env), + eq(products.id, pro.id), + ), + }); + expect(proV1).toBeDefined(); + const proV2InternalId = `prod_invoice_v2_${runTag}`; + await ctx.db.insert(products).values({ + ...proV1!, + internal_id: proV2InternalId, + version: proV1!.version + 1, + created_at: proV1!.created_at + 1, + }); + + const freePlan = await ctx.db.query.products.findFirst({ + where: and( + eq(products.org_id, ctx.org.id), + eq(products.env, ctx.env), + eq(products.id, free.id), + ), + }); + expect(freePlan).toBeDefined(); + + await Promise.all([ + autumnV2_2.customers.get(customerId, { + expand: [CustomerExpand.Invoices], + }), + autumnV2_2.customers.get(otherCustomerId, { + expand: [CustomerExpand.Invoices], + }), + ]); + + // ── Contract assertion 1: heterogeneous batch and response ordering ── + const inserted = await autumnV2_2.post("/invoices.insert", { + invoices: [ + { + customer_id: customerId, + entity_id: "ent-1", + plan_ids: [pro.id], + stripe_id: firstStripeId, + processor_type: "stripe", + status: "paid", + total: 29.99, + amount_paid: 29.99, + refunded_amount: 0, + created_at: originalCreatedAt, + hosted_invoice_url: "https://billing.example.com/invoices/first", + }, + { + customer_id: otherCustomerId, + plan_ids: [free.id], + stripe_id: secondStripeId, + status: "open", + total: 10, + created_at: originalCreatedAt + 1, + }, + ], + }); + + expect(inserted.invoices).toHaveLength(2); + expect( + inserted.invoices.map( + (invoice: { stripe_id: string }) => invoice.stripe_id, + ), + ).toEqual([firstStripeId, secondStripeId]); + expect(inserted.invoices[0]).toMatchObject({ + customer_id: customerId, + plan_ids: [pro.id], + stripe_id: firstStripeId, + processor_type: "stripe", + status: "paid", + total: 29.99, + amount_paid: 29.99, + refunded_amount: 0, + currency: ctx.org.default_currency, + created_at: originalCreatedAt, + hosted_invoice_url: "https://billing.example.com/invoices/first", + }); + expect("entity_id" in inserted.invoices[0]).toBe(false); + expect(inserted.invoices[1]).toMatchObject({ + customer_id: otherCustomerId, + plan_ids: [free.id], + stripe_id: secondStripeId, + processor_type: "stripe", + status: "open", + total: 10, + amount_paid: null, + refunded_amount: 0, + currency: ctx.org.default_currency, + created_at: originalCreatedAt + 1, + hosted_invoice_url: null, + }); + + // ── Contract assertion 2: external IDs resolve to internal IDs ─────── + const firstStored = await ctx.db.query.invoices.findFirst({ + where: eq(invoices.stripe_id, firstStripeId), + }); + const secondStored = await ctx.db.query.invoices.findFirst({ + where: eq(invoices.stripe_id, secondStripeId), + }); + expect(firstStored).toMatchObject({ + id: inserted.invoices[0].id, + internal_customer_id: primaryCustomer!.internal_id, + internal_entity_id: entity!.internal_id, + product_ids: [pro.id], + internal_product_ids: [proV2InternalId], + }); + expect(secondStored).toMatchObject({ + id: inserted.invoices[1].id, + internal_customer_id: otherCustomer!.internal_id, + internal_entity_id: null, + product_ids: [free.id], + internal_product_ids: [freePlan!.internal_id], + }); + + // ── Contract assertion 3: upsert fully replaces submitted fields ───── + const updatedCreatedAt = originalCreatedAt + 10_000; + const updated = await autumnV2_2.post("/invoices.insert", { + invoices: [ + { + customer_id: otherCustomerId, + plan_ids: [free.id], + stripe_id: firstStripeId, + processor_type: "revenuecat", + status: "void", + total: 40, + amount_paid: 20, + refunded_amount: 5, + currency: "eur", + created_at: updatedCreatedAt, + hosted_invoice_url: "https://billing.example.com/invoices/updated", + }, + { + customer_id: customerId, + plan_ids: [pro.id], + stripe_id: secondStripeId, + status: "paid", + total: 12, + amount_paid: 12, + created_at: updatedCreatedAt + 1, + }, + ], + }); + expect( + updated.invoices.map( + (invoice: { stripe_id: string }) => invoice.stripe_id, + ), + ).toEqual([firstStripeId, secondStripeId]); + expect(updated.invoices[0]).toMatchObject({ + id: inserted.invoices[0].id, + customer_id: otherCustomerId, + plan_ids: [free.id], + processor_type: "revenuecat", + status: "void", + total: 40, + amount_paid: 20, + refunded_amount: 5, + currency: "eur", + created_at: updatedCreatedAt, + hosted_invoice_url: "https://billing.example.com/invoices/updated", + }); + + const firstUpdatedStored = await ctx.db.query.invoices.findFirst({ + where: eq(invoices.stripe_id, firstStripeId), + }); + expect(firstUpdatedStored).toMatchObject({ + id: inserted.invoices[0].id, + internal_customer_id: otherCustomer!.internal_id, + internal_entity_id: null, + product_ids: [free.id], + internal_product_ids: [freePlan!.internal_id], + processor_type: "revenuecat", + status: "void", + total: 40, + amount_paid: 20, + refunded_amount: 5, + currency: "eur", + created_at: updatedCreatedAt, + hosted_invoice_url: "https://billing.example.com/invoices/updated", + }); + + // ── Contract assertion 4: caches are invalidated, not invoice-upserted ─ + const refreshedOtherCustomer = await autumnV2_2.customers.get( + otherCustomerId, + { + expand: [CustomerExpand.Invoices], + }, + ); + expect( + refreshedOtherCustomer.invoices.some( + (invoice: { stripe_id: string }) => invoice.stripe_id === firstStripeId, + ), + ).toBe(true); + + // ── Contract assertion 5: invalid references reject before writes ───── + const invalidStripeId = `in_invalid_${runTag}`; + expect( + autumnV2_2.post("/invoices.insert", { + invoices: [ + { + customer_id: customerId, + plan_ids: [pro.id], + stripe_id: `in_valid_sibling_${runTag}`, + status: "paid", + total: 1, + created_at: originalCreatedAt, + }, + { + customer_id: "missing-customer", + plan_ids: ["missing-plan"], + stripe_id: invalidStripeId, + status: "paid", + total: 1, + created_at: originalCreatedAt, + }, + ], + }), + ).rejects.toThrow(); + const invalidRows = await ctx.db.query.invoices.findMany({ + where: (invoice, { inArray }) => + inArray(invoice.stripe_id, [ + `in_valid_sibling_${runTag}`, + invalidStripeId, + ]), + }); + expect(invalidRows).toHaveLength(0); + + // ── Contract assertion 6: request size is capped at 500 ────────────── + expect( + autumnV2_2.post("/invoices.insert", { + invoices: Array.from({ length: 501 }, (_, index) => ({ + customer_id: customerId, + stripe_id: `in_too_many_${runTag}_${index}`, + status: "paid", + total: 1, + created_at: originalCreatedAt, + })), + }), + ).rejects.toThrow(); + }, +); diff --git a/server/tests/integration/licenses/billing/transitions/immediate-switch/attach-licenses-immediate-switch.test.ts b/server/tests/integration/licenses/billing/transitions/immediate-switch/attach-licenses-immediate-switch.test.ts index 05dc988791..9402afbfd0 100644 --- a/server/tests/integration/licenses/billing/transitions/immediate-switch/attach-licenses-immediate-switch.test.ts +++ b/server/tests/integration/licenses/billing/transitions/immediate-switch/attach-licenses-immediate-switch.test.ts @@ -566,3 +566,118 @@ test.concurrent( }); }, ); + +test.concurrent( + `${chalk.yellowBright("license transitions: dropped pools have no active assignments")}`, + async () => { + const customerId = "license-transition-dropped-pool"; + const team = products.base({ + id: "lic-dropped-team", + items: [items.dashboard()], + }); + const pro = products.base({ + id: "lic-dropped-pro", + items: [items.dashboard()], + }); + const teamSeat = products.base({ + id: "lic-dropped-team-seat", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const { autumnV2_3, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.entities({ count: 2, featureId: TestFeature.Users }), + s.products({ list: [team, pro, teamSeat] }), + ], + actions: [ + s.licenses.link({ + parentProductId: team.id, + licenseProductId: teamSeat.id, + included: 2, + }), + s.billing.attach({ productId: team.id }), + s.licenses.assign({ + licenseProductId: teamSeat.id, + entityIndexes: [0, 1], + }), + ], + }); + + await autumnV2_3.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV2_3.customers.get(customerId); + expectCustomerLicenses({ customer, licenses: [], count: 0 }); + const access = await autumnV2_3.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + }); + expect(access.allowed).toBe(false); + const assignments = await listLicenseAssignments({ + autumn: autumnV2_3, + customerId, + licensePlanId: teamSeat.id, + active: true, + }); + expect(assignments).toHaveLength(0); + }, +); + +test.concurrent( + `${chalk.yellowBright("license transitions: matched pools keep assignments active")}`, + async () => { + const customerId = "license-transition-matched-pool"; + const planA = products.base({ + id: "lic-matched-plan-a", + items: [items.dashboard()], + }); + const planB = products.base({ + id: "lic-matched-plan-b", + items: [items.dashboard()], + }); + const seat = products.base({ + id: "lic-matched-seat", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const { autumnV2_3, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.entities({ count: 2, featureId: TestFeature.Users }), + s.products({ list: [planA, planB, seat] }), + ], + actions: [ + ...[planA, planB].map((parent) => + s.licenses.link({ + parentProductId: parent.id, + licenseProductId: seat.id, + included: 2, + }), + ), + s.billing.attach({ productId: planA.id }), + s.licenses.assign({ + licenseProductId: seat.id, + entityIndexes: [0, 1], + }), + ], + }); + + await autumnV2_3.billing.attach({ + customer_id: customerId, + plan_id: planB.id, + redirect_mode: "if_required", + }); + const assignments = await listLicenseAssignments({ + autumn: autumnV2_3, + customerId, + licensePlanId: seat.id, + active: true, + }); + expect(assignments).toHaveLength(entities.length); + }, +); diff --git a/server/tests/integration/licenses/catalog-update/child-plan/catalog/child-and-parent-update.test.ts b/server/tests/integration/licenses/catalog-update/child-plan/catalog/child-and-parent-update.test.ts index ddf48e0f64..b227f48e3b 100644 --- a/server/tests/integration/licenses/catalog-update/child-plan/catalog/child-and-parent-update.test.ts +++ b/server/tests/integration/licenses/catalog-update/child-plan/catalog/child-and-parent-update.test.ts @@ -246,5 +246,5 @@ test.concurrent( expect.objectContaining({ feature_id: TestFeature.Words }), ); }, - { timeout: 15_000 }, + { timeout: 30_000 }, ); diff --git a/server/tests/integration/licenses/catalog-update/child-plan/propagate-base-license-edge-cases.test.ts b/server/tests/integration/licenses/catalog-update/child-plan/propagate-base-license-edge-cases.test.ts index 76f0982ecc..67e6616c42 100644 --- a/server/tests/integration/licenses/catalog-update/child-plan/propagate-base-license-edge-cases.test.ts +++ b/server/tests/integration/licenses/catalog-update/child-plan/propagate-base-license-edge-cases.test.ts @@ -514,7 +514,7 @@ test.concurrent( granted: 100, }); }, - { timeout: 15_000 }, + { timeout: 30_000 }, ); test.concurrent( diff --git a/server/tests/integration/licenses/catalog-update/license-catalog-response.test.ts b/server/tests/integration/licenses/catalog-update/license-catalog-response.test.ts index 38ac732afc..36b263d8e8 100644 --- a/server/tests/integration/licenses/catalog-update/license-catalog-response.test.ts +++ b/server/tests/integration/licenses/catalog-update/license-catalog-response.test.ts @@ -3,11 +3,14 @@ import type { CatalogPreviewUpdateResponse, CheckResponseV3, } from "@autumn/shared"; +import { CatalogUpdateParamsSchema } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; +import defaultCtx from "@tests/utils/testInitUtils/createTestContext.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; +import { previewUpdateCatalog } from "@/internal/catalog/actions/previewUpdateCatalog/previewUpdateCatalog.js"; import { listLicenseLinks, listLicensePools } from "../licenseTestUtils.js"; const makeLicenseProduct = () => ({ @@ -66,6 +69,102 @@ test.concurrent( }, ); +/** Fresh license plans must be complete FullProducts before customization. */ +test.concurrent( + `${chalk.yellowBright("licenses: catalog previews a customized same-batch license plan")}`, + async () => { + const suffix = Math.random().toString(36).slice(2, 9); + const seatId = `license_catalog_seat_${suffix}`; + const parentId = `license_catalog_parent_${suffix}`; + const standardId = `license_catalog_standard_${suffix}`; + const deepId = `license_catalog_deep_${suffix}`; + const creditsId = `license_catalog_credits_${suffix}`; + + const params = CatalogUpdateParamsSchema.parse({ + expand: ["plan_changes.plan"], + features: [ + { + feature_id: standardId, + name: "Standard Search", + type: "metered", + consumable: true, + }, + { + feature_id: deepId, + name: "Deep Search", + type: "metered", + consumable: true, + }, + { + feature_id: creditsId, + name: "AI Credits", + type: "credit_system", + credit_schema: [ + { metered_feature_id: standardId, credit_cost: 1 }, + { metered_feature_id: deepId, credit_cost: 10 }, + ], + }, + ], + plans: [ + { + plan_id: seatId, + version: 1, + name: "Team Seat", + licenses: [], + group: "", + add_on: false, + auto_enable: false, + items: [ + { + feature_id: creditsId, + included: 600, + reset: { interval: "month" }, + }, + ], + price: null, + free_trial: null, + }, + { + plan_id: parentId, + version: 1, + name: "Team Quarterly", + licenses: [ + { + license_plan_id: seatId, + included: 0, + prepaid_only: true, + customize: { + price: { + amount: 72, + interval: "quarter", + additional_currencies: [{ currency: "eur", amount: 72 }], + }, + }, + }, + ], + group: "", + add_on: false, + auto_enable: false, + price: null, + items: [], + free_trial: null, + }, + ], + }); + const preview = await previewUpdateCatalog({ ctx: defaultCtx, params }); + + expect(preview.plan_changes).toHaveLength(2); + expect(preview.plan_changes[1]?.plan?.licenses).toEqual([ + { + license_plan_id: seatId, + version: 1, + included: 0, + prepaid_only: true, + }, + ]); + }, +); + /** Fresh explicit versions must resolve and persist same-batch pinned license versions. */ test.concurrent( `${chalk.yellowBright("licenses: catalog creates fresh explicit versions with pinned dependencies")}`, diff --git a/server/tests/integration/licenses/catalog-update/license-link-validation.test.ts b/server/tests/integration/licenses/catalog-update/license-link-validation.test.ts index 7e922f7025..9b4f730dbb 100644 --- a/server/tests/integration/licenses/catalog-update/license-link-validation.test.ts +++ b/server/tests/integration/licenses/catalog-update/license-link-validation.test.ts @@ -1,7 +1,11 @@ +// Contract: license links reject pooled entitlements from base or customized license items. +// The pooled catalog plan remains valid, and rejection persists no parent-to-license link. + import { expect, test } from "bun:test"; import { type ApiPlanV1, ErrCode } from "@autumn/shared"; import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; import { items } from "@tests/utils/fixtures/items.js"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2.js"; import { products } from "@tests/utils/fixtures/products.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; @@ -113,3 +117,95 @@ test.concurrent( }); }, ); + +test.concurrent( + `${chalk.yellowBright("licenses catalog: pooled catalog items cannot be linked as a license")}`, + async () => { + const parent = products.base({ + id: "pooled-license-link-parent", + items: [items.dashboard()], + }); + const pooledLicense = products.base({ + id: "pooled-license-link-child", + items: [ + { + ...items.monthlyCredits({ includedUsage: 100 }), + pooled: true, + }, + ], + }); + const { autumnV2_2 } = await initScenario({ + customerId: "license-link-rejects-pooled-catalog-item", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [parent, pooledLicense] }), + ], + actions: [], + }); + + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + errMessage: "Pooled items are not supported for plan licenses", + func: () => + autumnV2_2.post("/plans.update", { + plan_id: parent.id, + licenses: [{ license_plan_id: pooledLicense.id, included: 1 }], + }), + }); + + const persistedParent = (await autumnV2_2.post("/plans.get", { + plan_id: parent.id, + })) as ApiPlanV1; + expect(persistedParent.licenses ?? []).toHaveLength(0); + }, +); + +test.concurrent( + `${chalk.yellowBright("licenses catalog: pooled customized items cannot be added to a license")}`, + async () => { + const parent = products.base({ + id: "pooled-license-custom-parent", + items: [items.dashboard()], + }); + const license = products.base({ + id: "pooled-license-custom-child", + items: [items.monthlyMessages({ includedUsage: 25 })], + }); + const { autumnV2_2 } = await initScenario({ + customerId: "license-link-rejects-pooled-custom-item", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [parent, license] }), + ], + actions: [], + }); + + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + errMessage: "Pooled items are not supported for plan licenses", + func: () => + autumnV2_2.post("/plans.update", { + plan_id: parent.id, + licenses: [ + { + license_plan_id: license.id, + included: 1, + customize: { + add_items: [ + { + ...itemsV2.monthlyCredits({ included: 100 }), + pooled: true, + }, + ], + }, + }, + ], + }), + }); + + const persistedParent = (await autumnV2_2.post("/plans.get", { + plan_id: parent.id, + })) as ApiPlanV1; + expect(persistedParent.licenses ?? []).toHaveLength(0); + }, +); diff --git a/server/tests/integration/licenses/catalog-update/license-sandbox-copy.test.ts b/server/tests/integration/licenses/catalog-update/license-sandbox-copy.test.ts index 2334b95329..1408a7bc1d 100644 --- a/server/tests/integration/licenses/catalog-update/license-sandbox-copy.test.ts +++ b/server/tests/integration/licenses/catalog-update/license-sandbox-copy.test.ts @@ -283,9 +283,10 @@ describe("sandboxes.copy: copy plans + features between two sandbox sub-orgs", ( const links = copied!.licenses ?? []; expect(links).toHaveLength(1); + // DB plan licenses carry internal ids; the public id is on the product. expect(links[0]).toMatchObject({ - license_plan_id: LICENSE_ID, included: 3, + product: expect.objectContaining({ id: LICENSE_ID }), }); const [storedLink] = await planLicenseRepo.listCatalogByParentInternalProductIds({ diff --git a/server/tests/integration/misc/balances/sync-dirty-state.test.ts b/server/tests/integration/misc/balances/sync-dirty-state.test.ts new file mode 100644 index 0000000000..00a228b5a0 --- /dev/null +++ b/server/tests/integration/misc/balances/sync-dirty-state.test.ts @@ -0,0 +1,302 @@ +/** + * TDD unit contract for the sync dirty-state primitives (sync-v4 coalescing). + * + * Contract under test: + * New modules (server/src/internal/balances/utils/sync/dirtyState/): + * - syncDirtyKeys.ts: buildSyncDirtyKeys({orgId, env, customerId}) + * -> { dirtyKey, claimKey, signalKey } + * - markSyncDirty.ts: markSyncDirty({redis, scope, cusEntIds, rolloverIds, + * modifiedCusEntIdsByFeatureId, usageWindowUpdates, entityId, + * signalTtlSeconds}) + * -> { shouldSignal: boolean } + * Behaviors: + * - first mark on empty state -> shouldSignal=true, signal marker set + * - second mark while signal marker live -> shouldSignal=false + * - selectors accumulate (set union) across marks + * - usage-window snapshots are last-write-wins per feature + * - claimSyncDirty.ts: claimSyncDirty({redis, scope}) + * -> merged dirty state or null when empty + * Behaviors: + * - moves dirty -> claim atomically; dirty key gone after claim + * - leftover claim (crashed worker) is MERGED with new dirty state + * on the next claim, never lost + * - clearSyncClaim({redis, scope, generation}) removes the owned claim + * Modified: + * - processMessage.shouldRetrySqsJobError: transient REDIS errors on the + * new signal job are retryable (message must stay in SQS) + * + * Pre-impl red: imports fail (modules do not exist). + * Post-impl green: all assertions pass against a real Redis (v2 cache instance). + */ + +import { expect, test } from "bun:test"; +import chalk from "chalk"; +import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js"; +import { + claimSyncDirty, + clearSyncClaim, +} from "@/internal/balances/utils/sync/dirtyState/claimSyncDirty.js"; +import { markSyncDirty } from "@/internal/balances/utils/sync/dirtyState/markSyncDirty.js"; +import { buildSyncDirtyKeys } from "@/internal/balances/utils/sync/dirtyState/syncDirtyKeys.js"; +import { JobName } from "@/queue/JobName.js"; +import { shouldRetrySqsJobError } from "@/queue/processMessage.js"; + +const scope = { + orgId: "org_dirtystate_unit", + env: "sandbox", + customerId: "unit-dirty-cus", +}; + +const wipe = async () => { + const redis = resolveRedisV2(); + const { dirtyKey, claimKey, signalKey } = buildSyncDirtyKeys(scope); + await redis.del(dirtyKey, claimKey, signalKey); + return redis; +}; + +test(`${chalk.yellowBright("dirty-state: mark signals only on empty->dirty transition")}`, async () => { + const redis = await wipe(); + + const first = await markSyncDirty({ + redis, + scope, + cusEntIds: ["ce_1"], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: { messages: ["ce_1"] }, + usageWindowUpdates: [], + signalTtlSeconds: 60, + }); + expect(first.shouldSignal).toBe(true); + + const second = await markSyncDirty({ + redis, + scope, + cusEntIds: ["ce_2"], + rolloverIds: ["ro_1"], + modifiedCusEntIdsByFeatureId: { messages: ["ce_2"] }, + usageWindowUpdates: [], + signalTtlSeconds: 60, + }); + expect(second.shouldSignal).toBe(false); +}); + +test(`${chalk.yellowBright("dirty-state: claim merges accumulated selectors and empties dirty")}`, async () => { + const redis = await wipe(); + + await markSyncDirty({ + redis, + scope, + cusEntIds: ["ce_1"], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: { messages: ["ce_1"] }, + usageWindowUpdates: [ + { + ts: 1, + update: { + internal_customer_id: "ic_1", + feature_id: "messages", + usage_windows: [{ usage: 1 }], + } as any, + }, + ], + signalTtlSeconds: 60, + }); + await markSyncDirty({ + redis, + scope, + cusEntIds: ["ce_2", "ce_1"], + rolloverIds: ["ro_1"], + modifiedCusEntIdsByFeatureId: { credits: ["ce_2"] }, + usageWindowUpdates: [ + { + ts: 2, + update: { + internal_customer_id: "ic_1", + feature_id: "messages", + usage_windows: [{ usage: 2 }], + } as any, + }, + ], + signalTtlSeconds: 60, + }); + + const claimed = await claimSyncDirty({ redis, scope }); + expect(claimed).not.toBeNull(); + // Set-union of selectors across both marks + expect(claimed!.cusEntIds.sort()).toEqual(["ce_1", "ce_2"]); + expect(claimed!.rolloverIds).toEqual(["ro_1"]); + expect(Object.keys(claimed!.modifiedCusEntIdsByFeatureId).sort()).toEqual([ + "credits", + "messages", + ]); + // Usage windows: LAST snapshot wins per feature + expect(claimed!.usageWindowUpdates).toHaveLength(1); + expect((claimed!.usageWindowUpdates[0] as any).usage_windows[0].usage).toBe( + 2, + ); + + // Dirty key is empty after claim + const { dirtyKey } = buildSyncDirtyKeys(scope); + expect(await redis.exists(dirtyKey)).toBe(0); + + await clearSyncClaim({ redis, scope, generation: claimed!.generation }); +}); + +test(`${chalk.yellowBright("dirty-state: leftover claim from crashed worker merges into next claim")}`, async () => { + const redis = await wipe(); + + // First cycle: mark + claim, then CRASH (claim never cleared). + await markSyncDirty({ + redis, + scope, + cusEntIds: ["ce_old"], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: { messages: ["ce_old"] }, + usageWindowUpdates: [], + signalTtlSeconds: 60, + }); + const firstClaim = await claimSyncDirty({ redis, scope }); + expect(firstClaim).not.toBeNull(); + // no clearSyncClaim: simulates a worker death mid-flush + + // New write arrives, then redelivery claims again. + await markSyncDirty({ + redis, + scope, + cusEntIds: ["ce_new"], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: { messages: ["ce_new"] }, + usageWindowUpdates: [], + signalTtlSeconds: 60, + }); + const secondClaim = await claimSyncDirty({ redis, scope }); + expect(secondClaim).not.toBeNull(); + // ce_old must survive the crash: merged, not lost + expect(secondClaim!.cusEntIds.sort()).toEqual(["ce_new", "ce_old"]); + + await clearSyncClaim({ redis, scope, generation: secondClaim!.generation }); +}); + +test(`${chalk.yellowBright("dirty-state: older usage-window snapshot does not overwrite newer")}`, async () => { + const redis = await wipe(); + + await markSyncDirty({ + redis, + scope, + cusEntIds: [], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: {}, + usageWindowUpdates: [ + { + ts: 2, + update: { + internal_customer_id: "ic_1", + feature_id: "messages", + usage_windows: [{ usage: 2 }], + } as any, + }, + ], + signalTtlSeconds: 60, + }); + await markSyncDirty({ + redis, + scope, + cusEntIds: [], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: {}, + usageWindowUpdates: [ + { + ts: 1, + update: { + internal_customer_id: "ic_1", + feature_id: "messages", + usage_windows: [{ usage: 1 }], + } as any, + }, + ], + signalTtlSeconds: 60, + }); + + const claimed = await claimSyncDirty({ redis, scope }); + expect((claimed!.usageWindowUpdates[0] as any).usage_windows[0].usage).toBe( + 2, + ); + await clearSyncClaim({ redis, scope, generation: claimed!.generation }); +}); + +test(`${chalk.yellowBright("dirty-state: stale generation cannot clear a newer claim")}`, async () => { + const redis = await wipe(); + + await markSyncDirty({ + redis, + scope, + cusEntIds: ["ce_1"], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: {}, + usageWindowUpdates: [], + signalTtlSeconds: 60, + }); + const firstClaim = await claimSyncDirty({ redis, scope }); + const secondClaim = await claimSyncDirty({ redis, scope }); + + await clearSyncClaim({ + redis, + scope, + generation: firstClaim!.generation, + }); + const { claimKey } = buildSyncDirtyKeys(scope); + expect(await redis.exists(claimKey)).toBe(1); + await clearSyncClaim({ + redis, + scope, + generation: secondClaim!.generation, + }); +}); + +test(`${chalk.yellowBright("dirty-state: colon-containing feature id roundtrips")}`, async () => { + const redis = await wipe(); + + await markSyncDirty({ + redis, + scope, + cusEntIds: [], + rolloverIds: [], + modifiedCusEntIdsByFeatureId: { "messages:email": ["ce:1"] }, + usageWindowUpdates: [ + { + ts: 1, + update: { + internal_customer_id: "ic_1", + feature_id: "messages:email", + usage_windows: [{ usage: 1 }], + } as any, + }, + ], + signalTtlSeconds: 60, + }); + + const claimed = await claimSyncDirty({ redis, scope }); + expect(claimed!.modifiedCusEntIdsByFeatureId).toEqual({ + "messages:email": ["ce:1"], + }); + expect(claimed!.usageWindowUpdates[0].feature_id).toBe("messages:email"); + await clearSyncClaim({ redis, scope, generation: claimed!.generation }); +}); + +test(`${chalk.yellowBright("dirty-state: claim on fully empty state returns null")}`, async () => { + const redis = await wipe(); + const claimed = await claimSyncDirty({ redis, scope }); + expect(claimed).toBeNull(); +}); + +test(`${chalk.yellowBright("dirty-state: transient redis errors on signal job are retryable")}`, () => { + const redisError = Object.assign(new Error("Connection is closed."), { + name: "ConnectionError", + }); + expect( + shouldRetrySqsJobError({ + jobName: JobName.SyncCustomerDirty, + error: redisError, + }), + ).toBe(true); +}); diff --git a/server/tests/unit/full-subject-gate/gate-rejection-failopen.test.ts b/server/tests/integration/misc/full-subject-gate/gate-rejection-failopen.test.ts similarity index 100% rename from server/tests/unit/full-subject-gate/gate-rejection-failopen.test.ts rename to server/tests/integration/misc/full-subject-gate/gate-rejection-failopen.test.ts diff --git a/server/tests/unit/full-subject-gate/get-full-subject-gate.test.ts b/server/tests/integration/misc/full-subject-gate/get-full-subject-gate.test.ts similarity index 100% rename from server/tests/unit/full-subject-gate/get-full-subject-gate.test.ts rename to server/tests/integration/misc/full-subject-gate/get-full-subject-gate.test.ts diff --git a/server/tests/integration/others/webhook-replay/webhook-fail-injection-replay.test.ts b/server/tests/integration/others/webhook-replay/webhook-fail-injection-replay.test.ts new file mode 100644 index 0000000000..57dbc39433 --- /dev/null +++ b/server/tests/integration/others/webhook-replay/webhook-fail-injection-replay.test.ts @@ -0,0 +1,168 @@ +/** + * End-to-end test for Stripe webhook failure injection + redelivery recovery. + * + * Contract under test: + * - billing.attach (latest version) metadata `test_webhook_fail` rides onto + * the Stripe checkout session, so checkout.session.completed carries it. + * NOTE: must use the latest client — AttachParamsV0Schema (V1 client) + * silently strips `metadata`. + * - While the marker is present on the LIVE Stripe object, EVERY delivery + * of the event fails (sync-ack 500, no false ack): the customer has PAID + * but the product stays unactivated — the outage failure mode, held open + * deterministically. + * - Removing the metadata from the live object ends the "outage": the next + * redelivery (triggered via Stripe's event retry endpoint — what `stripe + * events resend` uses; no Autumn worker code) activates the product + * exactly once. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { completeStripeCheckoutFormV2 } 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 type Stripe from "stripe"; + +const POLL_INTERVAL_MS = 500; +// Generous: Stripe's events API is eventually consistent and can lag. +const POLL_TIMEOUT_MS = 60_000; + +const waitFor = async ({ + fetch, + description, +}: { + fetch: () => Promise; + description: string; +}): Promise => { + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (true) { + const result = await fetch(); + if (result !== undefined) return result; + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for: ${description}`); + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } +}; + +const CHECKOUT_SESSION_ID_REGEX = /cs_(?:test|live)_[A-Za-z0-9]+/; + +// All initial deliveries (incl. dev duplicates) land well within this window. +const FAILED_DELIVERY_SETTLE_MS = 8_000; + +test.concurrent( + `${chalk.yellowBright("webhook-replay: deliveries fail while marker present, redelivery after removal activates once")}`, + async () => { + const customerId = "webhook-fail-injection-replay"; + + const pro = products.pro({ + id: "pro-webhook-replay", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), // No payment method → stripe checkout + s.products({ list: [pro] }), + ], + actions: [], + }); + + // ── 1. Attach with the failure-injection test option ────────── + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + metadata: { test_webhook_fail: "true" }, + }); + + expect(result.payment_url).toBeDefined(); + const sessionId = result.payment_url?.match(CHECKOUT_SESSION_ID_REGEX)?.[0]; + if (!sessionId) { + throw new Error(`No session id in payment_url: ${result.payment_url}`); + } + + // ── 2. Pay for real via Stripe checkout ─────────────────────── + await completeStripeCheckoutFormV2({ url: result.payment_url }); + + // ── 3. The completed event exists on Stripe and carries the marker ── + const stripeEvent = await waitFor({ + description: "checkout.session.completed event on Stripe", + fetch: async () => { + const events = await ctx.stripeCli.events.list({ + type: "checkout.session.completed", + limit: 100, + }); + return events.data.find( + (event) => + (event.data.object as Stripe.Checkout.Session).id === sessionId, + ); + }, + }); + const session = stripeEvent.data.object as Stripe.Checkout.Session; + expect(session.metadata?.test_webhook_fail).toBe("true"); + + // ── 4. Outage held open: every delivery fails while the marker is on + // the live session. Paid, but NOT provisioned. ────────────────── + await timeout(FAILED_DELIVERY_SETTLE_MS); + + const customerDuringOutage = + await autumnV1.customers.get(customerId); + const productDuringOutage = customerDuringOutage.products?.find( + (product) => product.id === pro.id, + ); + expect(productDuringOutage).toBeUndefined(); + + // ── 5. End the outage: remove the marker from the live objects ── + // (Empty string deletes a metadata key in Stripe.) + await ctx.stripeCli.checkout.sessions.update(sessionId, { + metadata: { test_webhook_fail: "" }, + }); + if (typeof session.subscription === "string") { + await ctx.stripeCli.subscriptions.update(session.subscription, { + metadata: { test_webhook_fail: "" }, + }); + } + + // ── 6. Ask STRIPE to redeliver the same event (no Autumn worker) ── + await ctx.stripeCli.rawRequest( + "POST", + `/v1/events/${stripeEvent.id}/retry`, + {}, + ); + + // ── 7. Recovered: product active, exactly once ──────────────── + const customerAfterRetry = await waitFor({ + description: "product activation after redelivery", + fetch: async () => { + const customer = + await autumnV1.customers.get(customerId); + const active = customer.products?.find( + (product) => product.id === pro.id, + ); + return active ? customer : undefined; + }, + }); + + await expectProductActive({ + customer: customerAfterRetry, + productId: pro.id, + }); + await expectCustomerInvoiceCorrect({ + customer: customerAfterRetry, + count: 1, + latestTotal: 20, + latestInvoiceProductId: pro.id, + }); + + const activations = customerAfterRetry.products?.filter( + (product) => product.id === pro.id, + ); + expect(activations?.length).toBe(1); + }, +); diff --git a/server/tests/integration/others/webhook-replay/webhook-queue-replay.test.ts b/server/tests/integration/others/webhook-replay/webhook-queue-replay.test.ts new file mode 100644 index 0000000000..a927d2d245 --- /dev/null +++ b/server/tests/integration/others/webhook-replay/webhook-queue-replay.test.ts @@ -0,0 +1,182 @@ +/** + * End-to-end test for the Stripe webhook SQS replay path. + * + * Uses an EARLY-ACKED event (invoice.updated) on purpose: the failed + * delivery was acked 200, so Stripe never redelivers — the queued replay job + * is provably the ONLY recovery path (unlike sync-acked events, where + * Stripe-side redelivery would race the queue). + * + * Contract under test: + * - Invoice-mode attach leaves an open Autumn invoice mirroring Stripe. + * - The invoice is voided externally in Stripe while carrying the + * `test_webhook_fail` marker: the invoice.updated webhook fails in the + * background after its early 200, so Autumn's invoice stays "open" — + * deterministic drift, held open until replayed. + * - A JobName.StripeWebhookReplay job — the same payload the server + * enqueues on early-ack failure — is consumed by the REAL worker process + * off SQS (primary dev queue; processMessage dispatches the job + * regardless of queue) and syncs the invoice to "void". + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +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 type Stripe from "stripe"; +import { JobName } from "@/queue/JobName"; +import { addTaskToQueue } from "@/queue/queueUtils"; + +const POLL_INTERVAL_MS = 500; +// Generous: Stripe's events API is eventually consistent and can lag. +const POLL_TIMEOUT_MS = 60_000; + +const waitFor = async ({ + fetch, + description, +}: { + fetch: () => Promise; + description: string; +}): Promise => { + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (true) { + const result = await fetch(); + if (result !== undefined) return result; + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for: ${description}`); + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } +}; + +// Early-acked failures return 200 — all deliveries settle well within this. +const FAILED_DELIVERY_SETTLE_MS = 8_000; + +test.concurrent( + `${chalk.yellowBright("webhook-replay: queued replay job recovers a failed early-acked invoice.updated")}`, + async () => { + const customerId = "webhook-queue-replay"; + + const pro = products.pro({ + id: "pro-webhook-queue-replay", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + invoice: true, + enableProductImmediately: true, + finalizeInvoice: true, + }), + ], + }); + + // ── 1. Invoice-mode attach → active product + open invoice ──── + const customerAfterAttach = + await autumnV1.customers.get(customerId); + const openInvoice = customerAfterAttach.invoices?.[0]; + if (!openInvoice?.stripe_id) { + throw new Error("Expected an invoice with stripe_id after attach"); + } + expect(openInvoice.status).toBe("open"); + + // ── 2. Arm the injection, then void the invoice in Stripe. The + // resulting invoice.updated is early-acked (200) and its background + // processing fails — Stripe will never redeliver. The outage SWITCH + // lives on the customer's metadata (marker value "customer"), so + // flipping it later emits no self-healing invoice events. ────── + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + openInvoice.stripe_id, + ); + const stripeCustomerId = + typeof stripeInvoice.customer === "string" + ? stripeInvoice.customer + : stripeInvoice.customer?.id; + if (!stripeCustomerId) { + throw new Error("Expected a stripe customer id on the invoice"); + } + + await ctx.stripeCli.customers.update(stripeCustomerId, { + metadata: { test_webhook_fail: "true" }, + }); + await ctx.stripeCli.invoices.update(openInvoice.stripe_id, { + metadata: { test_webhook_fail: "customer" }, + }); + await ctx.stripeCli.invoices.voidInvoice(openInvoice.stripe_id); + + // ── 3. Deterministic drift: Stripe says void, Autumn still open ── + await timeout(FAILED_DELIVERY_SETTLE_MS); + + const customerDuringOutage = + await autumnV1.customers.get(customerId); + expect(customerDuringOutage.invoices?.[0]?.status).toBe("open"); + + // ── 4. End the outage via the customer switch (emits only a benign + // customer.updated — no invoice event that could heal the drift), + // then grab the failed event from Stripe ────────────────────── + await ctx.stripeCli.customers.update(stripeCustomerId, { + metadata: { test_webhook_fail: "" }, + }); + + const voidEvent = await waitFor({ + description: "invoice.updated (void) event on Stripe", + fetch: async () => { + const events = await ctx.stripeCli.events.list({ + type: "invoice.updated", + limit: 100, + }); + return events.data.find((event) => { + const invoice = event.data.object as Stripe.Invoice; + return ( + invoice.id === openInvoice.stripe_id && invoice.status === "void" + ); + }); + }, + }); + + // ── 5. Recover via the QUEUE: same job the server enqueues on + // early-ack failure; the dev worker process consumes it. ────── + await addTaskToQueue({ + jobName: JobName.StripeWebhookReplay, + payload: { + orgId: ctx.org.id, + env: ctx.env, + stripeEvent: voidEvent, + failedAt: Date.now(), + failureReason: "simulated outage (webhook-queue-replay test)", + }, + }); + + // ── 6. Worker replay synced the invoice to void ─────────────── + await waitFor({ + description: "Autumn invoice syncing to void via worker replay job", + fetch: async () => { + const customer = + await autumnV1.customers.get(customerId); + const invoice = customer.invoices?.find( + (row) => row.stripe_id === openInvoice.stripe_id, + ); + return invoice?.status === "void" ? true : undefined; + }, + }); + + // Product untouched — the replay only synced invoice state. + const customerAfterReplay = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterReplay, + productId: pro.id, + }); + expect(customerAfterReplay.invoices?.length).toBe(1); + }, +); diff --git a/server/tests/integration/queue/fixtures/livePollingWorker.ts b/server/tests/integration/queue/fixtures/livePollingWorker.ts index 4dfab199ff..979dab86fa 100644 --- a/server/tests/integration/queue/fixtures/livePollingWorker.ts +++ b/server/tests/integration/queue/fixtures/livePollingWorker.ts @@ -23,6 +23,7 @@ let client = createClient(); await startPollingLoop({ db: {} as never, + queueId: "primary", queueUrl, isFifo: queueUrl.endsWith(".fifo"), getSqsClientFn: () => client, diff --git a/server/tests/integration/queue/start-polling-loop.test.ts b/server/tests/integration/queue/start-polling-loop.test.ts index 570c86600c..ca8864e2e8 100644 --- a/server/tests/integration/queue/start-polling-loop.test.ts +++ b/server/tests/integration/queue/start-polling-loop.test.ts @@ -14,12 +14,12 @@ const makeAbortError = () => { describe("startPollingLoop", () => { beforeEach(() => { - globalThis.setTimeout = (((callback: TimerHandler) => { + globalThis.setTimeout = ((callback: TimerHandler) => { if (typeof callback === "function") { callback(); } return 0 as unknown as ReturnType; - }) as unknown) as typeof setTimeout; + }) as unknown as typeof setTimeout; }); afterEach(() => { @@ -32,6 +32,7 @@ describe("startPollingLoop", () => { await startPollingLoop({ db: {} as never, + queueId: "track", queueUrl: "https://sqs.eu-west-1.amazonaws.com/123/track.fifo", isFifo: true, getSqsClientFn: () => @@ -64,6 +65,7 @@ describe("startPollingLoop", () => { await startPollingLoop({ db: {} as never, + queueId: "primary", queueUrl: "https://sqs.eu-west-1.amazonaws.com/123/primary.fifo", isFifo: true, getSqsClientFn: () => @@ -106,6 +108,7 @@ describe("startPollingLoop", () => { await startPollingLoop({ db: {} as never, + queueId: "track", queueUrl: "https://sqs.eu-west-1.amazonaws.com/123/track.fifo", isFifo: true, getSqsClientFn: () => makeClient(false), diff --git a/server/tests/integration/stripe/webhookAck/stripe-webhook-autumn-origin.test.ts b/server/tests/integration/stripe/webhookAck/stripe-webhook-autumn-origin.test.ts new file mode 100644 index 0000000000..d7ebdf495c --- /dev/null +++ b/server/tests/integration/stripe/webhookAck/stripe-webhook-autumn-origin.test.ts @@ -0,0 +1,67 @@ +/** + * TDD test for phase 2: Autumn-originated Stripe writes carry an + * "autumn:"-prefixed idempotency key, observable on the resulting Stripe + * events via event.request.idempotency_key. This is what lets the webhook + * ack classifier recognize "we caused this" with no per-type heuristics. + * + * Contract under test: + * New behaviors: + * - v2 billing.attach creates the Stripe subscription through + * executeStripeSubscriptionOperation with an idempotencyKey of the form + * autumn::. + * Side effects (observable via the Stripe Events API): + * - customer.subscription.created events produced by attach have + * event.request.idempotency_key starting with "autumn:". + * + * Pre-impl red: no billing write passes an idempotency key, so + * event.request.idempotency_key is a Stripe-generated value (no prefix). + * Post-impl green: subscription events from attach carry the autumn prefix. + */ + +import { expect, test } from "bun:test"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import type Stripe from "stripe"; + +test.concurrent( + `${chalk.yellowBright("webhookAck: attach-created subscription events carry autumn idempotency key")}`, + async () => { + const customerId = "webhook-ack-autumn-origin"; + const testStartSeconds = Math.floor(Date.now() / 1000) - 5; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + // ── Contract assertion: sub.created events from this attach are + // stamped with an autumn:-prefixed request idempotency key ───── + const events = await ctx.stripeCli.events.list({ + type: "customer.subscription.created", + created: { gte: testStartSeconds }, + limit: 50, + }); + + const attachSubEvents = events.data.filter((event) => { + const subscription = event.data.object as Stripe.Subscription; + return Boolean(subscription.metadata?.autumn_managed_at); + }); + + expect(attachSubEvents.length).toBeGreaterThan(0); + + for (const event of attachSubEvents) { + const idempotencyKey = event.request?.idempotency_key ?? ""; + expect(idempotencyKey.startsWith("autumn:")).toBe(true); + } + }, +); diff --git a/server/tests/integration/stripe/webhookAck/stripe-webhook-idempotency-retry.test.ts b/server/tests/integration/stripe/webhookAck/stripe-webhook-idempotency-retry.test.ts new file mode 100644 index 0000000000..0cd8148b1e --- /dev/null +++ b/server/tests/integration/stripe/webhookAck/stripe-webhook-idempotency-retry.test.ts @@ -0,0 +1,262 @@ +/** + * TDD test for the two-state Redis idempotency semantics behind Stripe webhook + * sync-ack. This is the regression guard for the outage failure mode: a failed + * webhook must stay replayable instead of being acked and lost. + * + * Contract under test: + * New behaviors: + * - First delivery acquires a processing lock and runs the handler. + * - Successful processing marks the event completed; a redelivery is acked + * 200 {received:true, duplicate:true} WITHOUT re-running the handler. + * - Failed sync processing returns 500 AND releases the lock, so Stripe's + * retry of the same event id runs the handler again (and can succeed). + * - A duplicate delivery while a sync event is still in flight returns 500 + * (Stripe retries later) instead of being swallowed as a duplicate. + * - Early-ack events still ack 200 immediately; after background success + * the Redis key holds "completed". + * Side effects: + * - Redis key stripe:webhook:{orgId}:{env}:{eventId} transitions + * (nil) -> "processing" -> "completed" | (deleted on failure). + * + * Pre-impl red: stripeWebhookAckMiddleware does not exist and the idempotency + * middleware acks duplicates before processing succeeds. + * Post-impl green: middlewares implement the two-state semantics above. + * + * Lives in integration (not unit) because it imports the real Redis client. + */ + +import { expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import chalk from "chalk"; +import { Hono } from "hono"; +import type Stripe from "stripe"; +import { redis } from "@/external/redis/initRedis"; +import { stripeIdempotencyMiddleware } from "@/external/stripe/webhookMiddlewares/stripeIdempotencyMiddleware"; +import { stripeWebhookAckMiddleware } from "@/external/stripe/webhookMiddlewares/stripeWebhookAckMiddleware"; + +const REDIS_READY_TIMEOUT_MS = 10_000; +const POLL_INTERVAL_MS = 100; + +const waitForRedisReady = async () => { + const deadline = Date.now() + REDIS_READY_TIMEOUT_MS; + while (redis.status !== "ready") { + if (Date.now() > deadline) { + throw new Error(`Redis never became ready (status: ${redis.status})`); + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } +}; + +const CONDITION_TIMEOUT_MS = 5000; + +const waitForCondition = async ({ + condition, + description, +}: { + condition: () => Promise; + description: string; +}) => { + const deadline = Date.now() + CONDITION_TIMEOUT_MS; + while (!(await condition())) { + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for: ${description}`); + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } +}; + +const buildSyncEvent = (eventId: string): Stripe.Event => + ({ + id: eventId, + type: "customer.subscription.updated", + data: { object: { metadata: {} } }, + request: null, + }) as unknown as Stripe.Event; + +const buildEarlyEvent = (eventId: string): Stripe.Event => + ({ + id: eventId, + type: "invoice.created", + data: { object: { billing_reason: "manual" } }, + request: null, + }) as unknown as Stripe.Event; + +const createWebhookApp = ({ + orgId, + event, + handler, +}: { + orgId: string; + event: Stripe.Event; + handler: () => Response | Promise; +}) => { + const app = new Hono(); + + app.use("*", async (c, next) => { + (c as never as { set: (key: string, value: unknown) => void }).set("ctx", { + org: { id: orgId }, + env: "sandbox", + stripeEvent: event, + logger: { error: () => {}, warn: () => {}, info: () => {} }, + }); + await next(); + }); + + app.post( + "/webhook", + stripeIdempotencyMiddleware as never, + stripeWebhookAckMiddleware as never, + handler as never, + ); + + return app; +}; + +const redisKeyFor = ({ orgId, eventId }: { orgId: string; eventId: string }) => + `stripe:webhook:${orgId}:sandbox:${eventId}`; + +test.concurrent( + `${chalk.yellowBright("webhookAck: successful sync event dedupes redeliveries without re-running the handler")}`, + async () => { + await waitForRedisReady(); + const orgId = `org_ack_${randomUUID()}`; + const eventId = `evt_${randomUUID()}`; + let handlerRuns = 0; + + const app = createWebhookApp({ + orgId, + event: buildSyncEvent(eventId), + handler: () => { + handlerRuns++; + return new Response(JSON.stringify({ success: true }), { status: 200 }); + }, + }); + + // ── First delivery processes inline ─────────────────────── + const first = await app.request("/webhook", { method: "POST" }); + expect(first.status).toBe(200); + expect(handlerRuns).toBe(1); + + // ── Side effect: key transitioned to completed ──────────── + const stored = await redis.get(redisKeyFor({ orgId, eventId })); + expect(stored).toBe("completed"); + + // ── Redelivery acked as duplicate, handler NOT re-run ───── + const second = await app.request("/webhook", { method: "POST" }); + expect(second.status).toBe(200); + expect(await second.json()).toEqual({ received: true, duplicate: true }); + expect(handlerRuns).toBe(1); + }, +); + +test.concurrent( + `${chalk.yellowBright("webhookAck: failed sync event returns 500 and a retry reprocesses it (outage regression)")}`, + async () => { + await waitForRedisReady(); + const orgId = `org_ack_${randomUUID()}`; + const eventId = `evt_${randomUUID()}`; + let handlerRuns = 0; + + const app = createWebhookApp({ + orgId, + event: buildSyncEvent(eventId), + handler: () => { + handlerRuns++; + if (handlerRuns === 1) { + throw new Error("simulated outage: handler dependency down"); + } + return new Response(JSON.stringify({ success: true }), { status: 200 }); + }, + }); + + // ── First delivery fails -> 500 so Stripe retries ───────── + const first = await app.request("/webhook", { method: "POST" }); + expect(first.status).toBe(500); + expect(handlerRuns).toBe(1); + + // ── Lock released: key must be gone, not "processing" ───── + const afterFailure = await redis.get(redisKeyFor({ orgId, eventId })); + expect(afterFailure).toBeNull(); + + // ── Stripe's retry of the SAME event id succeeds ────────── + const retry = await app.request("/webhook", { method: "POST" }); + expect(retry.status).toBe(200); + expect(handlerRuns).toBe(2); + + const afterSuccess = await redis.get(redisKeyFor({ orgId, eventId })); + expect(afterSuccess).toBe("completed"); + }, +); + +test.concurrent( + `${chalk.yellowBright("webhookAck: duplicate delivery of an in-flight sync event returns 500, not a false ack")}`, + async () => { + await waitForRedisReady(); + const orgId = `org_ack_${randomUUID()}`; + const eventId = `evt_${randomUUID()}`; + let resolveProcessing!: () => void; + const processing = new Promise((resolve) => { + resolveProcessing = resolve; + }); + + const app = createWebhookApp({ + orgId, + event: buildSyncEvent(eventId), + handler: async () => { + await processing; + return new Response(JSON.stringify({ success: true }), { status: 200 }); + }, + }); + + const firstDelivery = app.request("/webhook", { method: "POST" }); + // Let the first delivery acquire the lock before the duplicate arrives. + await waitForCondition({ + condition: async () => + (await redis.get(redisKeyFor({ orgId, eventId }))) === "processing", + description: "first delivery to acquire the processing lock", + }); + + const duplicate = await app.request("/webhook", { method: "POST" }); + expect(duplicate.status).toBe(500); + + resolveProcessing(); + const first = await firstDelivery; + expect(first.status).toBe(200); + }, +); + +test.concurrent( + `${chalk.yellowBright("webhookAck: early event acks immediately and marks completed after background success")}`, + async () => { + await waitForRedisReady(); + const orgId = `org_ack_${randomUUID()}`; + const eventId = `evt_${randomUUID()}`; + let handlerRuns = 0; + + const app = createWebhookApp({ + orgId, + event: buildEarlyEvent(eventId), + handler: () => { + handlerRuns++; + return new Response(JSON.stringify({ success: true }), { status: 200 }); + }, + }); + + const first = await app.request("/webhook", { method: "POST" }); + expect(first.status).toBe(200); + expect(await first.json()).toEqual({ received: true }); + + // Background processing marks completed asynchronously after the ack. + await waitForCondition({ + condition: async () => + (await redis.get(redisKeyFor({ orgId, eventId }))) === "completed", + description: "early-ack background processing to mark completed", + }); + expect(handlerRuns).toBe(1); + + const duplicate = await app.request("/webhook", { method: "POST" }); + expect(duplicate.status).toBe(200); + expect(await duplicate.json()).toEqual({ received: true, duplicate: true }); + expect(handlerRuns).toBe(1); + }, +); diff --git a/server/tests/unit/balances/track-v3/trackIdempotencyKey.test.ts b/server/tests/unit/balances/track-v3/trackIdempotencyKey.test.ts index af63965901..89afcf8d56 100644 --- a/server/tests/unit/balances/track-v3/trackIdempotencyKey.test.ts +++ b/server/tests/unit/balances/track-v3/trackIdempotencyKey.test.ts @@ -1,6 +1,13 @@ import { expect, test } from "bun:test"; -import { AppEnv } from "@autumn/shared"; -import { getTrackIdempotencyKey } from "@/internal/balances/track/v3/trackIdempotencyKey.js"; +import { AppEnv, ms } from "@autumn/shared"; +import { + getTrackIdempotencyKey, + TRACK_V3_IDEMPOTENCY_TTL_MS, +} from "@/internal/balances/track/v3/trackIdempotencyKey.js"; + +test("track v3 idempotency keys expire after one day", () => { + expect(TRACK_V3_IDEMPOTENCY_TTL_MS).toBe(ms.days(1)); +}); test("getTrackIdempotencyKey uses the request id", () => { expect( diff --git a/server/tests/unit/balances/track-v3/trackV3Helpers.test.ts b/server/tests/unit/balances/track-v3/trackV3Helpers.test.ts index 4ca5c0a293..138abc5d57 100644 --- a/server/tests/unit/balances/track-v3/trackV3Helpers.test.ts +++ b/server/tests/unit/balances/track-v3/trackV3Helpers.test.ts @@ -7,6 +7,7 @@ import { type Feature, type FullCustomerEntitlement, type FullSubject, + fullSubjectToCustomerEntitlements, type Organization, SubjectType, } from "@autumn/shared"; @@ -203,6 +204,38 @@ const buildFeatureDeduction = (): FeatureDeduction => }) as FeatureDeduction; describe("track v3 helpers", () => { + test("fullSubjectToCustomerEntitlements excludes pooled source entitlements", () => { + const fullSubject = buildFullSubject({ + subjectType: "customer", + balance: 10, + }); + const sourceCustomerEntitlement = buildExtraCustomerEntitlement({ + balance: 0, + }); + sourceCustomerEntitlement.entitlement.pooled = true; + const pooledCustomerEntitlement = buildExtraCustomerEntitlement({ + balance: 10, + }); + pooledCustomerEntitlement.id = "cus_ent_pool"; + pooledCustomerEntitlement.is_pooled_balance = true; + pooledCustomerEntitlement.entitlement.pooled = true; + fullSubject.customer_products = [ + { + status: "active", + customer_entitlements: [sourceCustomerEntitlement], + } as FullSubject["customer_products"][number], + ]; + fullSubject.extra_customer_entitlements = []; + fullSubject.pooled_customer_entitlements = [pooledCustomerEntitlement]; + + const customerEntitlements = fullSubjectToCustomerEntitlements({ + fullSubject, + }); + + expect(customerEntitlements).toHaveLength(1); + expect(customerEntitlements[0].id).toBe("cus_ent_pool"); + }); + test("applyDeductionUpdateToFullSubject updates extra customer entitlements", () => { const fullSubject = buildFullSubject({ subjectType: "customer", @@ -226,6 +259,34 @@ describe("track v3 helpers", () => { expect(fullSubject.extra_customer_entitlements[0].adjustment).toBe(2); }); + test("applyDeductionUpdateToFullSubject updates pooled customer entitlements", () => { + const fullSubject = buildFullSubject({ + subjectType: "customer", + balance: 10, + }); + const pooledCustomerEntitlement = buildExtraCustomerEntitlement({ + balance: 10, + }); + pooledCustomerEntitlement.id = "cus_ent_pool"; + fullSubject.extra_customer_entitlements = []; + fullSubject.pooled_customer_entitlements = [pooledCustomerEntitlement]; + + applyDeductionUpdateToFullSubject({ + fullSubject, + customerEntitlementId: "cus_ent_pool", + update: { + balance: 6, + additional_balance: 0, + entities: {}, + adjustment: 2, + deducted: 4, + }, + }); + + expect(fullSubject.pooled_customer_entitlements[0].balance).toBe(6); + expect(fullSubject.pooled_customer_entitlements[0].adjustment).toBe(2); + }); + test("applyRolloverUpdatesToFullSubject updates nested rollovers", () => { const fullSubject = buildFullSubject({ subjectType: "customer", @@ -252,6 +313,36 @@ describe("track v3 helpers", () => { ); }); + test("applyRolloverUpdatesToFullSubject updates pooled rollovers", () => { + const fullSubject = buildFullSubject({ + subjectType: "customer", + balance: 10, + }); + const pooledCustomerEntitlement = buildExtraCustomerEntitlement({ + balance: 10, + }); + fullSubject.extra_customer_entitlements = []; + fullSubject.pooled_customer_entitlements = [pooledCustomerEntitlement]; + + applyRolloverUpdatesToFullSubject({ + fullSubject, + rolloverUpdates: { + roll_1: { + balance: 1, + usage: 3, + entities: {}, + }, + }, + }); + + expect( + fullSubject.pooled_customer_entitlements[0].rollovers[0].balance, + ).toBe(1); + expect(fullSubject.pooled_customer_entitlements[0].rollovers[0].usage).toBe( + 3, + ); + }); + test("deductionToTrackResponseV2 builds customer-subject balances", async () => { const ctx = buildCtx(); const fullSubject = buildFullSubject({ diff --git a/server/tests/unit/balances/track/batch-track-retry-dedup.test.ts b/server/tests/unit/balances/track/batch-track-retry-dedup.test.ts index d99a7c01c6..c4e0e5f4fb 100644 --- a/server/tests/unit/balances/track/batch-track-retry-dedup.test.ts +++ b/server/tests/unit/balances/track/batch-track-retry-dedup.test.ts @@ -141,7 +141,10 @@ describe("runBatchTrack — retry-dedup regression pin (cubic P1)", () => { }); test("two requests with the same body produce DIFFERENT MessageDeduplicationId values per index (current accepted behavior — client retry duplicates)", async () => { - await runBatchTrack({ ctx: buildCtx({ requestId: "req_pin_first" }), body }); + await runBatchTrack({ + ctx: buildCtx({ requestId: "req_pin_first" }), + body, + }); const firstCall = mockState.queueCommands[0]; mockState.queueCommands = []; @@ -200,7 +203,7 @@ describe("runBatchTrack — retry-dedup regression pin (cubic P1)", () => { }); }); - test("MessageGroupId is `${orgId}:${env}:${customerId}:${entityId ?? 'none'}` for each item", async () => { + test("MessageGroupId preserves the org, environment, customer, and entity routing prefix", async () => { const ctx = buildCtx({ requestId: "req_pin_group" }); await runBatchTrack({ ctx, body }); @@ -208,10 +211,14 @@ describe("runBatchTrack — retry-dedup regression pin (cubic P1)", () => { const entries = mockState.queueCommands[0]?.Entries ?? []; expect(entries).toHaveLength(3); - expect(entries[0]?.MessageGroupId).toBe("org_pin:sandbox:cus_pin_a:none"); - expect(entries[1]?.MessageGroupId).toBe( + expect(entries[0]?.MessageGroupId).toStartWith( + "org_pin:sandbox:cus_pin_a:none", + ); + expect(entries[1]?.MessageGroupId).toStartWith( "org_pin:sandbox:cus_pin_b:ent_pin_1", ); - expect(entries[2]?.MessageGroupId).toBe("org_pin:sandbox:cus_pin_c:none"); + expect(entries[2]?.MessageGroupId).toStartWith( + "org_pin:sandbox:cus_pin_c:none", + ); }); }); diff --git a/server/tests/unit/balances/track/handle-track-tokens.test.ts b/server/tests/unit/balances/track/handle-track-tokens.test.ts index e51f529c21..2fe788b3fb 100644 --- a/server/tests/unit/balances/track/handle-track-tokens.test.ts +++ b/server/tests/unit/balances/track/handle-track-tokens.test.ts @@ -1,16 +1,9 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { - afterEach, - beforeEach, - describe, - expect, - mock, - test, -} from "bun:test"; -import { - ApiVersion, ApiVersionClass, AppEnv, FeatureType, + LATEST_VERSION, } from "@autumn/shared"; import type { SQSClient } from "@aws-sdk/client-sqs"; import { Hono } from "hono"; @@ -114,7 +107,7 @@ const createCtx = (): AutumnContext => id: "req_track_tokens_1", org: { id: "org_123" }, env: AppEnv.Sandbox, - apiVersion: new ApiVersionClass(ApiVersion.V2_1), + apiVersion: new ApiVersionClass(LATEST_VERSION), features: [{ id: "ai_credits", type: FeatureType.AiCreditSystem }], extraLogs: {}, scopes: [], @@ -188,13 +181,20 @@ describe("handleTrackTokens", () => { }); expect(response.status).toBe(202); - expect(await response.json()).toEqual({ success: true }); + expect(await response.json()).toEqual({ + customer_id: "cus_123", + entity_id: "ent_123", + value: 3.5, + balance: null, + }); expect(mockState.queueCommands).toHaveLength(1); expect(mockState.queueCommands[0]).toMatchObject({ QueueUrl: trackAsyncQueueUrl, - MessageGroupId: "org_123:sandbox:cus_123:ent_123", MessageDeduplicationId: "req_track_tokens_1", }); + expect(mockState.queueCommands[0]?.MessageGroupId).toMatch( + /^org_123:sandbox:cus_123:ent_123:shard-[0-7]$/, + ); expect( JSON.parse(mockState.queueCommands[0]?.MessageBody as string), ).toMatchObject({ diff --git a/server/tests/unit/balances/track/handle-track.test.ts b/server/tests/unit/balances/track/handle-track.test.ts index ce77854fd4..558afdebef 100644 --- a/server/tests/unit/balances/track/handle-track.test.ts +++ b/server/tests/unit/balances/track/handle-track.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { ApiVersion, ApiVersionClass, AppEnv } from "@autumn/shared"; +import { ApiVersionClass, AppEnv, LATEST_VERSION } from "@autumn/shared"; import type { SQSClient } from "@aws-sdk/client-sqs"; import { Hono } from "hono"; import type { AutumnContext, HonoEnv } from "@/honoUtils/HonoEnv.js"; @@ -15,12 +15,12 @@ const mockState = { import { handleTrack } from "@/internal/balances/handlers/handleTrack.js"; -const createCtx = (): AutumnContext => +const createCtx = ({ orgSlug = "test-org" } = {}): AutumnContext => ({ id: "req_track_1", - org: { id: "org_123" }, + org: { id: "org_123", slug: orgSlug }, env: AppEnv.Sandbox, - apiVersion: new ApiVersionClass(ApiVersion.V2_1), + apiVersion: new ApiVersionClass(LATEST_VERSION), features: [{ id: "messages" }], extraLogs: {}, scopes: [], @@ -79,11 +79,37 @@ describe("handleTrack", () => { }); expect(response.status).toBe(202); - expect(await response.json()).toEqual({ success: true }); + expect(await response.json()).toEqual({ + customer_id: "cus_123", + value: 1, + balance: null, + }); expect(mockState.queueCommands).toHaveLength(1); expect(mockState.queueCommands[0]).toMatchObject({ QueueUrl: trackAsyncQueueUrl, MessageDeduplicationId: "req_track_1", }); }); + + test("returns 202 success for Firecrawl track", async () => { + const response = await createApp({ + ctx: createCtx({ orgSlug: "firecrawl" }), + }).request("/track", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + customer_id: "cus_123", + feature_id: "messages", + value: 1, + }), + }); + + expect(response.status).toBe(202); + expect(await response.json()).toEqual({ + customer_id: "cus_123", + value: 1, + balance: null, + }); + expect(mockState.queueCommands).toHaveLength(1); + }); }); diff --git a/server/tests/unit/balances/track/runAsyncTrack.test.ts b/server/tests/unit/balances/track/runAsyncTrack.test.ts index 61e24b2dd8..3277e32dec 100644 --- a/server/tests/unit/balances/track/runAsyncTrack.test.ts +++ b/server/tests/unit/balances/track/runAsyncTrack.test.ts @@ -75,9 +75,11 @@ describe("runAsyncTrack", () => { expect(ctx.extraLogs.trackQueuedForReplay).toBeUndefined(); expect(mockState.queueCommands[0]).toMatchObject({ QueueUrl: trackAsyncQueueUrl, - MessageGroupId: "org_123:sandbox:cus_123:none", MessageDeduplicationId: "req_async_1", }); + expect(mockState.queueCommands[0]?.MessageGroupId).toMatch( + /^org_123:sandbox:cus_123:none:shard-[0-7]$/, + ); expect( JSON.parse(mockState.queueCommands[0]?.MessageBody as string), ).toMatchObject({ @@ -89,6 +91,19 @@ describe("runAsyncTrack", () => { }); }); + test("uses a deterministic shard derived from the message deduplication ID", async () => { + const firstCtx = buildCtx(); + const secondCtx = buildCtx(); + + await runAsyncTrack({ ctx: firstCtx, body }); + await runAsyncTrack({ ctx: secondCtx, body }); + + expect(mockState.queueCommands).toHaveLength(2); + expect(mockState.queueCommands[0]?.MessageGroupId).toBe( + mockState.queueCommands[1]?.MessageGroupId, + ); + }); + test("throws 503 RecaseError when TRACK_ASYNC_SQS_QUEUE_URL is unset", async () => { process.env.TRACK_ASYNC_SQS_QUEUE_URL = undefined; const ctx = buildCtx(); diff --git a/server/tests/unit/balances/track/runBatchTrack.test.ts b/server/tests/unit/balances/track/runBatchTrack.test.ts index 6410facda1..5cd344ef3a 100644 --- a/server/tests/unit/balances/track/runBatchTrack.test.ts +++ b/server/tests/unit/balances/track/runBatchTrack.test.ts @@ -139,19 +139,25 @@ describe("runBatchTrack", () => { expect(mockState.queueCommands[0]?.Entries).toHaveLength(3); expect(mockState.queueCommands[0]?.Entries?.[0]).toMatchObject({ Id: "0", - MessageGroupId: "org_123:sandbox:cus_123:none", MessageDeduplicationId: "req_batch_1-0", }); + expect(mockState.queueCommands[0]?.Entries?.[0]?.MessageGroupId).toMatch( + /^org_123:sandbox:cus_123:none:shard-[0-7]$/, + ); expect(mockState.queueCommands[0]?.Entries?.[1]).toMatchObject({ Id: "1", - MessageGroupId: "org_123:sandbox:cus_123:ent_123", MessageDeduplicationId: "req_batch_1-1", }); + expect(mockState.queueCommands[0]?.Entries?.[1]?.MessageGroupId).toMatch( + /^org_123:sandbox:cus_123:ent_123:shard-[0-7]$/, + ); expect(mockState.queueCommands[0]?.Entries?.[2]).toMatchObject({ Id: "2", - MessageGroupId: "org_123:sandbox:cus_456:none", MessageDeduplicationId: "req_batch_1-2", }); + expect(mockState.queueCommands[0]?.Entries?.[2]?.MessageGroupId).toMatch( + /^org_123:sandbox:cus_456:none:shard-[0-7]$/, + ); expect( JSON.parse(mockState.queueCommands[0]?.Entries?.[1]?.MessageBody ?? "{}"), ).toMatchObject({ @@ -314,17 +320,29 @@ describe("runBatchTrack", () => { for (const command of mockState.queueCommands) { expect(command.Entries).toHaveLength(10); } + const shardIds = new Set( + mockState.queueCommands + .flatMap((command) => command.Entries ?? []) + .map((entry) => entry.MessageGroupId?.split(":").pop()), + ); + expect(shardIds).toEqual( + new Set(Array.from({ length: 8 }, (_, index) => `shard-${index}`)), + ); expect(mockState.queueCommands[0]?.Entries?.[0]).toMatchObject({ Id: "0", - MessageGroupId: "org_123:sandbox:cus_0:none", MessageDeduplicationId: "req_batch_1-0", }); + expect(mockState.queueCommands[0]?.Entries?.[0]?.MessageGroupId).toMatch( + /^org_123:sandbox:cus_0:none:shard-[0-7]$/, + ); expect(mockState.queueCommands[99]?.Entries?.[9]).toMatchObject({ Id: "9", - MessageGroupId: "org_123:sandbox:cus_999:none", MessageDeduplicationId: "req_batch_1-999", }); + expect(mockState.queueCommands[99]?.Entries?.[9]?.MessageGroupId).toMatch( + /^org_123:sandbox:cus_999:none:shard-[0-7]$/, + ); }); test("rejects empty batch body at schema parse time", () => { diff --git a/server/tests/unit/billing/rollback/rollback.test.ts b/server/tests/unit/billing/rollback/rollback.test.ts new file mode 100644 index 0000000000..3577ad1066 --- /dev/null +++ b/server/tests/unit/billing/rollback/rollback.test.ts @@ -0,0 +1,316 @@ +import { expect, test } from "bun:test"; +import { type AutumnBillingPlan, CusProductStatus } from "@autumn/shared"; +import { computeRollbackPlan } from "@/internal/billing/v2/actions/rollback/compute/computeRollbackPlan"; +import { applyAutumnBillingPlanToFullCustomer } from "@/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer"; +import { + makeAutumnBillingPlan, + makeCustomerEntitlement, + makePatch, + makeUpdate, +} from "../billing-change-response/helpers/makeAutumnBillingPlan"; +import { makeFullCusProduct } from "../billing-change-response/helpers/makeFullCusProduct"; +import { makeFullCustomer } from "../billing-change-response/helpers/makeFullCustomer"; + +test.concurrent( + "computes a plan that restores the original customer state", + () => { + const updated = makeFullCusProduct({ planId: "updated" }); + const patched = makeFullCusProduct({ planId: "patched" }); + const deleted = makeFullCusProduct({ planId: "deleted" }); + const inserted = makeFullCusProduct({ planId: "inserted" }); + const oldEntitlement = makeCustomerEntitlement({ featureId: "old" }); + const newEntitlement = makeCustomerEntitlement({ featureId: "new" }); + oldEntitlement.customer_product_id = patched.id; + newEntitlement.customer_product_id = patched.id; + const replaceable = { + id: "rep_old", + cus_ent_id: oldEntitlement.id, + created_at: 1_700_000_000_000, + from_entity_id: null, + delete_next_cycle: false, + }; + oldEntitlement.replaceables = [replaceable]; + patched.customer_entitlements = [oldEntitlement]; + + const before = makeFullCustomer({ + customerProducts: [updated, patched, deleted], + }); + const autumnBillingPlan: AutumnBillingPlan = { + ...makeAutumnBillingPlan({ + inserts: [inserted], + update: makeUpdate({ + customerProduct: updated, + updates: { status: CusProductStatus.Expired, canceled_at: 123 }, + }), + deletes: [deleted], + patches: [ + makePatch({ + customerProduct: patched, + insertEntitlements: [newEntitlement], + deleteEntitlements: [oldEntitlement], + }), + ], + }), + updateCustomerEntitlements: [ + { + customerEntitlement: oldEntitlement, + balanceChange: 10, + }, + { + customerEntitlement: newEntitlement, + balanceChange: 20, + }, + ], + }; + const committed = applyAutumnBillingPlanToFullCustomer({ + fullCustomer: before, + autumnBillingPlan, + }); + + const rollbackPlan = computeRollbackPlan({ autumnBillingPlan }); + const restored = applyAutumnBillingPlanToFullCustomer({ + fullCustomer: committed, + autumnBillingPlan: rollbackPlan, + }); + + expect(restored).toEqual(before); + expect(autumnBillingPlan.insertCustomerProducts).toEqual([inserted]); + expect(rollbackPlan.updateCustomerEntitlements).toContainEqual({ + customerEntitlement: oldEntitlement, + insertReplaceables: [replaceable], + }); + }, +); + +test.concurrent("restores only fields touched by absolute updates", () => { + const customerProduct = makeFullCusProduct({ planId: "updated" }); + const customerEntitlement = makeCustomerEntitlement({ + featureId: "messages", + }); + customerEntitlement.customer_product_id = customerProduct.id; + customerEntitlement.balance = 100; + customerEntitlement.adjustment = 5; + customerProduct.customer_entitlements = [customerEntitlement]; + const autumnBillingPlan: AutumnBillingPlan = { + ...makeAutumnBillingPlan({ + update: makeUpdate({ + customerProduct, + updates: { canceled_at: null }, + }), + }), + updateCustomerEntitlements: [ + { + customerEntitlement, + balanceChange: 50, + updates: { balance: 25 }, + }, + ], + }; + + const rollbackPlan = computeRollbackPlan({ autumnBillingPlan }); + + expect(rollbackPlan.updateCustomerProducts).toEqual([ + { + customerProduct: { ...customerProduct, canceled_at: null }, + updates: { canceled_at: customerProduct.canceled_at }, + }, + ]); + expect(rollbackPlan.updateCustomerEntitlements).toEqual([ + { + customerEntitlement, + updates: { balance: 100 }, + }, + ]); +}); + +test.concurrent( + "reverses entitlement deltas and replaceables in reverse order", + () => { + const customerProduct = makeFullCusProduct({ planId: "updated" }); + const first = makeCustomerEntitlement({ featureId: "first" }); + const second = makeCustomerEntitlement({ featureId: "second" }); + first.customer_product_id = customerProduct.id; + second.customer_product_id = customerProduct.id; + const insertedReplaceable = { + id: "rep_inserted", + cus_ent_id: first.id, + created_at: 1_700_000_000_000, + }; + const deletedReplaceable = { + ...insertedReplaceable, + id: "rep_deleted", + from_entity_id: null, + delete_next_cycle: false, + }; + const autumnBillingPlan: AutumnBillingPlan = { + ...makeAutumnBillingPlan(), + updateCustomerEntitlements: [ + { + customerEntitlement: first, + balanceChange: 20, + insertReplaceables: [insertedReplaceable], + deletedReplaceables: [deletedReplaceable], + }, + { + customerEntitlement: second, + balanceChange: -5, + }, + ], + }; + + expect( + computeRollbackPlan({ autumnBillingPlan }).updateCustomerEntitlements, + ).toEqual([ + { + customerEntitlement: second, + balanceChange: 5, + insertReplaceables: undefined, + deletedReplaceables: undefined, + }, + { + customerEntitlement: first, + balanceChange: -20, + insertReplaceables: [deletedReplaceable], + deletedReplaceables: [ + { + ...insertedReplaceable, + from_entity_id: null, + delete_next_cycle: false, + }, + ], + }, + ]); + }, +); + +test.concurrent( + "ignores inert fields and rejects populated unsupported operations", + () => { + const inertPlan = { + ...makeAutumnBillingPlan(), + customPrices: [], + customEntitlements: [], + insertCustomerEntitlements: [], + lineItems: [], + customLineItems: [], + }; + + expect(computeRollbackPlan({ autumnBillingPlan: inertPlan })).toEqual({ + customerId: inertPlan.customerId, + insertCustomerProducts: [], + }); + + for (const field of [ + "insertEntities", + "customPrices", + "insertPlanLicenses", + "customerLicenseUpdates", + "oneOffPurchaseRebalance", + "autoTopupRebalance", + "schedulePhaseCustomerProductReplacements", + "futureOperation", + ]) { + const autumnBillingPlan = { + ...makeAutumnBillingPlan(), + [field]: [{}], + } as AutumnBillingPlan; + + expect(() => computeRollbackPlan({ autumnBillingPlan })).toThrow(field); + } + + const customerProduct = makeFullCusProduct({ planId: "overlap" }); + expect( + computeRollbackPlan({ + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [customerProduct], + deletes: [customerProduct], + }), + }), + ).toEqual({ + customerId: "cus_test", + insertCustomerProducts: [], + }); + }, +); + +test.concurrent( + "omits operations dominated by product lifecycle changes", + () => { + const customerProduct = makeFullCusProduct({ planId: "transient" }); + const customerEntitlement = makeCustomerEntitlement({ + featureId: "transient", + }); + customerEntitlement.customer_product_id = customerProduct.id; + const autumnBillingPlan: AutumnBillingPlan = { + ...makeAutumnBillingPlan({ + inserts: [customerProduct], + updates: [ + makeUpdate({ + customerProduct, + updates: { status: CusProductStatus.Expired }, + }), + ], + patches: [makePatch({ customerProduct })], + }), + updateCustomerEntitlements: [{ customerEntitlement, balanceChange: 1 }], + }; + + expect(computeRollbackPlan({ autumnBillingPlan })).toEqual({ + customerId: autumnBillingPlan.customerId, + insertCustomerProducts: [], + deleteCustomerProducts: [customerProduct], + }); + }, +); + +test.concurrent( + "rejects duplicate deletes that rollback would reinsert", + () => { + const customerProduct = makeFullCusProduct({ planId: "duplicate" }); + const customerEntitlement = makeCustomerEntitlement({ + featureId: "duplicate", + }); + customerEntitlement.customer_product_id = customerProduct.id; + const replaceable = { + id: "rep_duplicate", + cus_ent_id: customerEntitlement.id, + created_at: 1_700_000_000_000, + from_entity_id: null, + delete_next_cycle: false, + }; + + const plans = [ + makeAutumnBillingPlan({ + deleteOne: customerProduct, + deletes: [customerProduct], + }), + makeAutumnBillingPlan({ + patches: [ + makePatch({ + customerProduct, + deleteEntitlements: [customerEntitlement, customerEntitlement], + }), + ], + }), + { + ...makeAutumnBillingPlan(), + updateCustomerEntitlements: [ + { + customerEntitlement, + deletedReplaceables: [replaceable], + }, + { + customerEntitlement, + deletedReplaceables: [replaceable], + }, + ], + }, + ] satisfies AutumnBillingPlan[]; + + for (const autumnBillingPlan of plans) { + expect(() => computeRollbackPlan({ autumnBillingPlan })).toThrow( + "duplicate", + ); + } + }, +); diff --git a/server/tests/unit/billing/v2/pooledBalances/applyPooledBalancePlanToFullCustomer.test.ts b/server/tests/unit/billing/v2/pooledBalances/applyPooledBalancePlanToFullCustomer.test.ts new file mode 100644 index 0000000000..f922b29913 --- /dev/null +++ b/server/tests/unit/billing/v2/pooledBalances/applyPooledBalancePlanToFullCustomer.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import type { + AutumnBillingPlan, + FullCusProduct, + FullCustomer, + FullCustomerEntitlement, +} from "@autumn/shared"; +import { applyAutumnBillingPlanToFullCustomer } from "@/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer"; + +test("projects pooled balance and contribution operations onto the final customer", () => { + const sourceCustomerEntitlement = { + id: "source-entitlement", + balance: 100, + adjustment: 10, + additional_balance: 5, + entities: { entity: { balance: 100 } }, + pooled_balance_id: null, + pooled_contribution_id: null, + } as unknown as FullCustomerEntitlement; + const customerProduct = { + id: "customer-product", + customer_entitlements: [sourceCustomerEntitlement], + } as FullCusProduct; + const fullCustomer = { + customer_products: [customerProduct], + pooled_customer_entitlements: [], + } as unknown as FullCustomer; + const pooledCustomerEntitlement = { + id: "pooled-customer-entitlement", + balance: 100, + pooled_balance: { id: "pool", granted: 100 }, + } as unknown as FullCustomerEntitlement; + const contribution = { + id: "contribution", + pooled_balance_id: "pool", + source_customer_product_id: customerProduct.id, + source_customer_entitlement_id: sourceCustomerEntitlement.id, + current_contribution: 100, + next_cycle_contribution: 100, + effective_at: null, + created_at: 1, + updated_at: 1, + }; + const autumnBillingPlan = { + customerId: "customer", + insertCustomerProducts: [], + pooledBalancePlan: { + insertPoolBalances: [pooledCustomerEntitlement], + updatePoolBalances: [], + insertPoolContributions: [contribution], + updatePoolContributions: [], + deletePoolContributions: [], + }, + } satisfies AutumnBillingPlan; + + const result = applyAutumnBillingPlanToFullCustomer({ + fullCustomer, + autumnBillingPlan, + }); + + expect(result.customer_products[0].customer_entitlements[0]).toMatchObject({ + pooled_contribution_id: contribution.id, + pooled_balance_id: null, + balance: 0, + adjustment: 0, + additional_balance: 0, + entities: null, + pooled_balance_contribution: contribution, + }); + expect(result.pooled_customer_entitlements).toEqual([ + pooledCustomerEntitlement, + ]); + expect(sourceCustomerEntitlement.balance).toBe(100); + expect(sourceCustomerEntitlement.pooled_contribution_id).toBeNull(); +}); diff --git a/server/tests/unit/billing/v2/pooledBalances/applyPooledBalancePlanToIncomingCustomerProducts.test.ts b/server/tests/unit/billing/v2/pooledBalances/applyPooledBalancePlanToIncomingCustomerProducts.test.ts new file mode 100644 index 0000000000..0ab90d76f7 --- /dev/null +++ b/server/tests/unit/billing/v2/pooledBalances/applyPooledBalancePlanToIncomingCustomerProducts.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import type { + FullCusProduct, + FullCustomerEntitlement, + PooledBalancePlan, +} from "@autumn/shared"; +import { applyPooledBalancePlanToIncomingCustomerProducts } from "@/internal/billing/v2/pooledBalances/compute/applyPooledBalancePlanToIncomingCustomerProducts"; + +test("normalizes incoming pooled source entitlements from the finalized plan", () => { + const sourceCustomerEntitlement = { + id: "source-entitlement", + balance: 100, + adjustment: 10, + additional_balance: 5, + entities: { entity: { balance: 100 } }, + pooled_balance_id: "old-pool", + pooled_contribution_id: null, + } as unknown as FullCustomerEntitlement; + const customerProduct = { + customer_entitlements: [sourceCustomerEntitlement], + } as FullCusProduct; + const pooledBalancePlan = { + insertPoolBalances: [], + updatePoolBalances: [], + insertPoolContributions: [ + { + id: "contribution", + pooled_balance_id: "pool", + source_customer_product_id: "customer-product", + source_customer_entitlement_id: sourceCustomerEntitlement.id, + current_contribution: 100, + next_cycle_contribution: 100, + effective_at: null, + created_at: 1, + updated_at: 1, + }, + ], + updatePoolContributions: [], + deletePoolContributions: [], + } satisfies PooledBalancePlan; + + applyPooledBalancePlanToIncomingCustomerProducts({ + customerProducts: [customerProduct], + pooledBalancePlan, + }); + + expect(customerProduct.customer_entitlements[0]).toMatchObject({ + pooled_contribution_id: "contribution", + pooled_balance_id: null, + balance: 0, + adjustment: 0, + additional_balance: 0, + entities: null, + }); +}); diff --git a/server/tests/unit/billing/v2/pooledBalances/finalizePooledBalanceTransitionPlan.test.ts b/server/tests/unit/billing/v2/pooledBalances/finalizePooledBalanceTransitionPlan.test.ts new file mode 100644 index 0000000000..062f17589d --- /dev/null +++ b/server/tests/unit/billing/v2/pooledBalances/finalizePooledBalanceTransitionPlan.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test"; +import type { + DbPooledBalanceContribution, + InsertPooledBalanceContribution, + PooledBalancePlan, +} from "@autumn/shared"; +import { finalizePooledBalanceTransitionPlan } from "@/internal/billing/v2/pooledBalances/compute/finalizePooledBalanceTransitionPlan"; + +const currentContribution = ({ + id = "current", + poolId = "pool", + sourceEntitlementId = "source-entitlement", + current = 100, + next = 100, +}: { + id?: string; + poolId?: string; + sourceEntitlementId?: string; + current?: number; + next?: number; +} = {}): DbPooledBalanceContribution => ({ + id, + pooled_balance_id: poolId, + source_customer_product_id: "source-product", + source_customer_entitlement_id: sourceEntitlementId, + current_contribution: current, + next_cycle_contribution: next, + effective_at: null, + created_at: 1, + updated_at: 1, +}); + +const insertedContribution = ({ + id = "inserted", + poolId = "pool", + sourceEntitlementId = "source-entitlement", + current = 100, + next = 100, +}: { + id?: string; + poolId?: string; + sourceEntitlementId?: string; + current?: number; + next?: number; +} = {}): InsertPooledBalanceContribution => ({ + id, + pooled_balance_id: poolId, + source_customer_product_id: "source-product", + source_customer_entitlement_id: sourceEntitlementId, + current_contribution: current, + next_cycle_contribution: next, + effective_at: null, + created_at: 2, + updated_at: 2, +}); + +const transitionPlan = ({ + current, + incoming, +}: { + current: DbPooledBalanceContribution; + incoming: InsertPooledBalanceContribution; +}): PooledBalancePlan => ({ + insertPoolBalances: [], + updatePoolBalances: [], + insertPoolContributions: [incoming], + updatePoolContributions: [], + deletePoolContributions: [current], +}); + +test("identical contribution replacement finalizes to a no-op", () => { + const result = finalizePooledBalanceTransitionPlan({ + pooledBalancePlan: transitionPlan({ + current: currentContribution(), + incoming: insertedContribution(), + }), + }); + + expect(result).toBeUndefined(); +}); + +test("changed contribution replacement preserves the existing row", () => { + const result = finalizePooledBalanceTransitionPlan({ + pooledBalancePlan: transitionPlan({ + current: currentContribution(), + incoming: insertedContribution({ + poolId: "new-pool", + current: 200, + next: 200, + }), + }), + }); + + expect(result?.insertPoolContributions).toEqual([]); + expect(result?.deletePoolContributions).toEqual([]); + expect(result?.updatePoolContributions).toEqual([ + { + ...currentContribution(), + pooled_balance_id: "new-pool", + current_contribution: 200, + next_cycle_contribution: 200, + updated_at: 2, + }, + ]); +}); + +test("different sources remain independent insert and delete operations", () => { + const result = finalizePooledBalanceTransitionPlan({ + pooledBalancePlan: transitionPlan({ + current: currentContribution({ sourceEntitlementId: "outgoing" }), + incoming: insertedContribution({ sourceEntitlementId: "incoming" }), + }), + }); + + expect(result?.insertPoolContributions).toHaveLength(1); + expect(result?.updatePoolContributions).toEqual([]); + expect(result?.deletePoolContributions).toHaveLength(1); +}); diff --git a/server/tests/unit/catalog/catalog-plan-dependencies.test.ts b/server/tests/unit/catalog/catalog-plan-dependencies.test.ts index c9e290dd28..791480ca5b 100644 --- a/server/tests/unit/catalog/catalog-plan-dependencies.test.ts +++ b/server/tests/unit/catalog/catalog-plan-dependencies.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import type { CatalogPlanParams } from "@autumn/shared"; +import type { CatalogPlanParams, FullProduct } from "@autumn/shared"; import { sortCatalogPlansByDependencies, validateCatalogPlanVersionTargets, @@ -9,7 +9,8 @@ const plan = ( plan_id: string, version?: number, licenses?: CatalogPlanParams["licenses"], -): CatalogPlanParams => ({ plan_id, version, licenses }); + new_plan_id?: string, +): CatalogPlanParams => ({ plan_id, version, licenses, new_plan_id }); test.concurrent( "unpinned dependencies select the highest batch version", @@ -34,3 +35,15 @@ test.concurrent("fresh plans must start at version one", () => { }), ).toThrow("version must be 1, received 2"); }); + +test.concurrent("renamed plans sequence versions under their target id", () => { + const plans = [ + plan("legacy", 2, undefined, "current"), + plan("legacy", 3, undefined, "current"), + ]; + const products = [{ id: "current", version: 1 } as FullProduct]; + + expect(() => + validateCatalogPlanVersionTargets({ plans, products }), + ).not.toThrow(); +}); diff --git a/server/tests/unit/cron/batch-reset-scan-gates.test.ts b/server/tests/unit/cron/batch-reset-scan-gates.test.ts new file mode 100644 index 0000000000..f8fa7a4782 --- /dev/null +++ b/server/tests/unit/cron/batch-reset-scan-gates.test.ts @@ -0,0 +1,121 @@ +/** + * Unit tests for the V2 reset scan gates (queue-depth backpressure). + * + * The gates import getBatchResetQueueDepth directly, so queue depth is + * simulated with mock.module; poll cadence comes from the resetJobV2 edge + * config via its testing setter. + * + * Contract under test: + * Gate A (waitForQueueBelowHighWater): + * - passes immediately when depth <= high water + * - blocks while depth > high water, resumes once it drains + * - fails open when no dedicated queue is configured (null depth) + * - fails open when the depth check throws + * Gate B (waitForQueueDrained): + * - requires TWO consecutive zero reads before returning (approximate + * SQS counts) + * - a non-zero read resets the consecutive-zero counter + * - fails open on null depth + * - respects an aborted signal + */ + +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import type { BatchResetQueueDepth } from "@/internal/balances/batchReset/concurrency/getBatchResetQueueDepth.js"; +import { ResetJobV2ConfigSchema } from "@/internal/misc/resetJobV2/resetJobV2Schemas.js"; +import { setResetJobV2ConfigForTesting } from "@/internal/misc/resetJobV2/resetJobV2Store.js"; + +// Queue of depth results the mocked getBatchResetQueueDepth serves in order, +// repeating the last entry. `null` = no dedicated queue; "error" = SQS throw. +let depthResults: (number | null | "error")[] = []; +let depthCalls = 0; + +mock.module( + "@/internal/balances/batchReset/concurrency/getBatchResetQueueDepth.js", + () => ({ + getBatchResetQueueDepth: (): Promise => { + const index = Math.min(depthCalls, depthResults.length - 1); + depthCalls++; + const total = depthResults[index]; + if (total === "error") return Promise.reject(new Error("sqs down")); + if (total === null) return Promise.resolve(null); + return Promise.resolve({ visible: total, inFlight: 0, total }); + }, + }), +); + +const { waitForQueueBelowHighWater, waitForQueueDrained } = await import( + "@/internal/balances/batchReset/concurrency/batchResetScanGates.js" +); +const { logger } = await import("@/external/logtail/logtailUtils.js"); + +const setDepthSequence = (results: (number | null | "error")[]) => { + depthResults = results; + depthCalls = 0; +}; + +const liveSignal = () => new AbortController().signal; + +beforeEach(() => { + setResetJobV2ConfigForTesting({ + config: ResetJobV2ConfigSchema.parse({ + queueHighWaterMessages: 20, + // Schema minimum; keeps blocked-gate tests reasonably fast. + queueDepthPollMs: 1_000, + }), + }); +}); + +describe("waitForQueueBelowHighWater (gate A)", () => { + test("passes immediately when depth is at or below high water", async () => { + setDepthSequence([20]); + await waitForQueueBelowHighWater({ logger, signal: liveSignal() }); + expect(depthCalls).toBe(1); + }); + + test("blocks while above high water and resumes once drained", async () => { + setDepthSequence([50, 30, 5]); + await waitForQueueBelowHighWater({ logger, signal: liveSignal() }); + expect(depthCalls).toBe(3); + }); + + test("fails open when no dedicated queue is configured", async () => { + setDepthSequence([null]); + await waitForQueueBelowHighWater({ logger, signal: liveSignal() }); + expect(depthCalls).toBe(1); + }); + + test("fails open when the depth check throws", async () => { + setDepthSequence(["error"]); + await waitForQueueBelowHighWater({ logger, signal: liveSignal() }); + expect(depthCalls).toBe(1); + }); +}); + +describe("waitForQueueDrained (gate B)", () => { + test("returns only after two consecutive zero reads", async () => { + setDepthSequence([3, 0, 0]); + await waitForQueueDrained({ logger, signal: liveSignal() }); + expect(depthCalls).toBe(3); + }); + + test("a non-zero read resets the consecutive-zero counter", async () => { + // zero, blip back to non-zero, then two zeros required again + setDepthSequence([0, 2, 0, 0]); + await waitForQueueDrained({ logger, signal: liveSignal() }); + expect(depthCalls).toBe(4); + }); + + test("fails open when no dedicated queue is configured", async () => { + setDepthSequence([null]); + await waitForQueueDrained({ logger, signal: liveSignal() }); + expect(depthCalls).toBe(1); + }); + + test("returns promptly when the signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + setDepthSequence([100]); + await waitForQueueDrained({ logger, signal: controller.signal }); + expect(depthCalls).toBe(0); + }); +}); diff --git a/server/tests/unit/cron/reset-batch.test.ts b/server/tests/unit/cron/reset-batch.test.ts new file mode 100644 index 0000000000..6102c7dac6 --- /dev/null +++ b/server/tests/unit/cron/reset-batch.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, mock, test } from "bun:test"; +import { AppEnv, type ResetCusEnt } from "@autumn/shared"; +import type { CronContext } from "@/cron/utils/CronContext.js"; + +const cusEnt = { + id: "cus-ent-1", + customer: { + id: "customer-1", + org_id: "org-1", + env: AppEnv.Sandbox, + }, +} as ResetCusEnt; +const calls = { + fetchParams: null as unknown, + reset: 0, + cleared: [] as ResetCusEnt[], +}; +const batchSize = 1_000; + +mock.module( + "@/internal/customers/cusProducts/cusEnts/CusEntitlementService", + () => ({ + CusEntService: { + getActiveResetPassed: async (params: unknown) => { + calls.fetchParams = params; + return [cusEnt]; + }, + upsert: async () => undefined, + }, + }), +); +mock.module("@/internal/orgs/OrgService.js", () => ({ + OrgService: { + getWithFeatures: async () => ({ + org: { id: "org-1", config: {}, redis_config: null }, + features: [], + }), + }, +})); +mock.module("@/internal/misc/resetJob/resetJobStore.js", () => ({ + getResetJobConfig: () => ({ enabled: true, batchSize }), +})); +mock.module("@/cron/resetCron/resetCustomerEntitlement", () => ({ + resetCustomerEntitlement: async ({ + updatedCusEnts, + }: { + updatedCusEnts: ResetCusEnt[]; + }) => { + calls.reset++; + updatedCusEnts.push(cusEnt); + }, +})); +mock.module("@/cron/resetCron/clearCusEntsFromCache", () => ({ + clearCusEntsFromCache: async ({ cusEnts }: { cusEnts: ResetCusEnt[] }) => { + calls.cleared = cusEnts; + }, +})); + +import { runResetBatch } from "@/cron/resetCron/runResetBatch.js"; + +describe("reset batch", () => { + test("processes a partial page instead of waiting for a full batch", async () => { + const result = await runResetBatch({ + ctx: { + db: {}, + logger: { info: () => undefined, error: () => undefined }, + } as unknown as CronContext, + }); + + expect(calls.fetchParams).toMatchObject({ + batchSize, + limit: batchSize, + }); + expect(calls.reset).toBe(1); + expect(calls.cleared).toEqual([cusEnt]); + expect(result.batchSize).toBe(batchSize); + expect(result.fetched).toBe(1); + }); +}); diff --git a/server/tests/unit/cron/reset-loop.test.ts b/server/tests/unit/cron/reset-loop.test.ts new file mode 100644 index 0000000000..718d6656a8 --- /dev/null +++ b/server/tests/unit/cron/reset-loop.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { runResetLoop } from "@/cron/resetCron/runResetLoop.js"; +import type { CronContext } from "@/cron/utils/CronContext.js"; + +const BATCH_SIZE = 1_000; + +const ctx = { + db: {}, + logger: { + error: () => undefined, + }, +} as unknown as CronContext; + +describe("reset loop", () => { + test("does no work while disabled", async () => { + const controller = new AbortController(); + let batches = 0; + const delays: number[] = []; + + await runResetLoop({ + ctx, + signal: controller.signal, + isEnabled: () => false, + isActive: () => true, + runBatch: async () => { + batches++; + return { + batchSize: BATCH_SIZE, + fetched: 0, + upserted: 0, + durationMs: 0, + }; + }, + wait: async ({ delayMs }) => { + delays.push(delayMs); + controller.abort(); + }, + }); + + expect(batches).toBe(0); + expect(delays).toEqual([5_000]); + }); + + test("awaits each batch and drains full pages faster", async () => { + const controller = new AbortController(); + const delays: number[] = []; + let batches = 0; + let activeBatches = 0; + let maxActiveBatches = 0; + + await runResetLoop({ + ctx, + signal: controller.signal, + isEnabled: () => true, + isActive: () => true, + runBatch: async () => { + batches++; + activeBatches++; + maxActiveBatches = Math.max(maxActiveBatches, activeBatches); + await Promise.resolve(); + activeBatches--; + return { + batchSize: BATCH_SIZE, + fetched: batches === 1 ? BATCH_SIZE : 1, + upserted: 0, + durationMs: 0, + }; + }, + wait: async ({ delayMs }) => { + delays.push(delayMs); + if (delays.length === 2) controller.abort(); + }, + }); + + expect(batches).toBe(2); + expect(maxActiveBatches).toBe(1); + expect(delays).toEqual([1_000, 5_000]); + }); + + test("does no work on the idle blue-green slot", async () => { + const controller = new AbortController(); + let batches = 0; + + await runResetLoop({ + ctx, + signal: controller.signal, + isEnabled: () => true, + isActive: () => false, + runBatch: async () => { + batches++; + return { + batchSize: BATCH_SIZE, + fetched: 0, + upserted: 0, + durationMs: 0, + }; + }, + wait: async () => controller.abort(), + }); + + expect(batches).toBe(0); + }); +}); diff --git a/server/tests/unit/customers/dashboard-product-filter.test.ts b/server/tests/unit/customers/dashboard-product-filter.test.ts index 8cc8cf9b93..9caf34a4b8 100644 --- a/server/tests/unit/customers/dashboard-product-filter.test.ts +++ b/server/tests/unit/customers/dashboard-product-filter.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { sql } from "drizzle-orm"; +import { AppEnv } from "@autumn/shared"; +import { type SQL, sql } from "drizzle-orm"; import { PgDialect } from "drizzle-orm/pg-core"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { CusSearchService } from "@/internal/customers/CusSearchService.js"; import { getCustomerListFilterSql, parseDashboardVersionFilter, @@ -57,4 +60,28 @@ describe("dashboard product filters", () => { expect(normalized).toContain("cp_dash.is_custom = true"); expect(params).toEqual(["active", "past_due", "pro", 2, "pro"]); }); + + /** Red: numbered plan search joined globally; green: products are scoped to the requesting org and environment. */ + test("numbered plan cursor lookup scopes the product join", async () => { + let capturedQuery: ReturnType | undefined; + const db = { + execute: async (query: SQL) => { + capturedQuery = dialect.sqlToQuery(query); + return []; + }, + } as unknown as DrizzleCli; + + await CusSearchService.resolveInternalIdsByCursor({ + db, + orgId: "org_target", + env: AppEnv.Live, + search: "", + filters: { version: ["enterprise:1"] }, + limit: 50, + }); + + const query = normalize(capturedQuery!.sql); + expect(query).toContain('"products"."org_id" ='); + expect(query).toContain('"products"."env" ='); + }); }); diff --git a/server/tests/unit/customers/full-customer-cache-redis-gating.test.ts b/server/tests/unit/customers/full-customer-cache-redis-gating.test.ts index c258ec5f6d..2b7905904f 100644 --- a/server/tests/unit/customers/full-customer-cache-redis-gating.test.ts +++ b/server/tests/unit/customers/full-customer-cache-redis-gating.test.ts @@ -14,6 +14,22 @@ const mockState = { updateDetailsCalls: 0, }; +// Real modules captured BEFORE mocking so afterAll can restore them — module +// mocks leak across test files (mock.restore does not undo them). +const MOCKED_MODULE_PATHS = [ + "@/internal/customers/CusService.js", + "@/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.js", + "@/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullCustomer.js", + "@/internal/customers/cusUtils/getFullCustomerSchedule.js", + "@/internal/customers/actions/index.js", + "@/internal/entities/handlers/handleCreateEntity/autoCreateEntity.js", + "@/internal/customers/cusUtils/cusUtils.js", +] as const; +const realModules = new Map>(); +for (const path of MOCKED_MODULE_PATHS) { + realModules.set(path, { ...(await import(path)) }); +} + mock.module("@/internal/customers/CusService.js", () => ({ CusService: { getFull: async () => { @@ -180,5 +196,8 @@ describe("full customer cache Redis gating", () => { }); afterAll(() => { + for (const [path, realModule] of realModules) { + mock.module(path, () => realModule); + } mock.restore(); }); diff --git a/server/tests/unit/customers/recovery/get-or-create-recovery-capture.test.ts b/server/tests/unit/customers/recovery/get-or-create-recovery-capture.test.ts new file mode 100644 index 0000000000..bf3b135d1a --- /dev/null +++ b/server/tests/unit/customers/recovery/get-or-create-recovery-capture.test.ts @@ -0,0 +1,157 @@ +/** + * TDD contract for wiring customer get-or-create overload failures to recovery. + * + * Contract under test: + * - A transient FullSubject failure is still returned as the existing overload 503. + * - The validated request is captured once with the execution stage at failure. + * - Recovery workers can explicitly disable capture to prevent recursive enqueue. + */ + +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { ApiVersion, ApiVersionClass, AppEnv } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; + +const transientError = Object.assign(new Error("connect timeout"), { + code: "CONNECT_TIMEOUT", +}); + +const mockState = { + queueCalls: [] as Record[], +}; + +// Real modules captured BEFORE mocking so afterAll can restore them — module +// mocks leak across test files (mock.restore does not undo them). +const MOCKED_MODULE_PATHS = [ + "@/internal/misc/rollouts/fullSubjectRolloutUtils.js", + "@/internal/customers/cache/fullSubject/index.js", + "@/internal/customers/recovery/queueFailedCustomerCreation.js", + "@/internal/customers/actions/ensureStripeCustomerFromCustomerData.js", + "@/internal/customers/cusUtils/apiCusUtils/getApiCustomer.js", + "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js", + "@/internal/customers/cusUtils/getApiCustomerV2/index.js", +] as const; +const realModules = new Map>(); +for (const path of MOCKED_MODULE_PATHS) { + realModules.set(path, { ...(await import(path)) }); +} + +afterAll(() => { + for (const [path, realModule] of realModules) { + mock.module(path, () => realModule); + } +}); + +mock.module("@/internal/misc/rollouts/fullSubjectRolloutUtils.js", () => ({ + isFullSubjectRolloutEnabled: () => true, +})); + +mock.module("@/internal/customers/cache/fullSubject/index.js", () => ({ + getOrCreateCachedFullSubject: async () => { + throw transientError; + }, +})); + +mock.module( + "@/internal/customers/recovery/queueFailedCustomerCreation.js", + () => ({ + queueFailedCustomerCreation: async (args: Record) => { + mockState.queueCalls.push(args); + return true; + }, + }), +); + +mock.module( + "@/internal/customers/actions/ensureStripeCustomerFromCustomerData.js", + () => ({ + ensureStripeCustomerFromCustomerData: async () => {}, + }), +); + +mock.module( + "@/internal/customers/cusUtils/apiCusUtils/getApiCustomer.js", + () => ({ + getApiCustomer: () => ({ id: "legacy" }), + }), +); + +mock.module( + "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js", + () => ({ + getOrCreateCachedFullCustomer: async () => ({ id: "legacy" }), + }), +); + +mock.module("@/internal/customers/cusUtils/getApiCustomerV2/index.js", () => ({ + getApiCustomerV2: () => ({ id: "v2" }), +})); + +const { getOrCreateApiCustomerByRollout } = await import( + // @ts-expect-error - Bun test cache-busting import query isolates module mocks. + "@/internal/customers/actions/getOrCreateApiCustomerByRollout.js?customerCreationCapture" +); + +const buildContext = () => + ({ + id: "req_customer_123", + org: { id: "org_123" }, + env: AppEnv.Live, + apiVersion: new ApiVersionClass(ApiVersion.V2_1), + extraLogs: {}, + logger: { + warn: mock(() => {}), + error: mock(() => {}), + }, + }) as unknown as AutumnContext; + +const params = { + customer_id: "customer_123", + customer_data: { email: "customer@example.com" }, +}; + +describe("getOrCreateApiCustomerByRollout recovery capture", () => { + beforeEach(() => { + mockState.queueCalls = []; + }); + + test("captures the validated request while preserving overload response semantics", async () => { + const ctx = buildContext(); + + await expect( + getOrCreateApiCustomerByRollout({ + ctx, + params, + source: "handleGetOrCreateCustomerV2", + withAutumnId: true, + }), + ).rejects.toMatchObject({ + statusCode: 503, + data: { reason: "critical_db_saturated" }, + }); + + expect(mockState.queueCalls).toEqual([ + expect.objectContaining({ + ctx, + params, + source: "handleGetOrCreateCustomerV2", + withAutumnId: true, + failureStage: "lookup", + }), + ]); + }); + + test("does not recursively capture a recovery replay", async () => { + const ctx = buildContext(); + + await expect( + getOrCreateApiCustomerByRollout({ + ctx, + params, + source: "customerCreationRecovery", + enqueueRecoveryOnTransientFailure: false, + }), + ).rejects.toMatchObject({ statusCode: 503 }); + + expect(mockState.queueCalls).toHaveLength(0); + }); +}); diff --git a/server/tests/unit/customers/recovery/queue-failed-customer-creation.test.ts b/server/tests/unit/customers/recovery/queue-failed-customer-creation.test.ts new file mode 100644 index 0000000000..03ff840a0c --- /dev/null +++ b/server/tests/unit/customers/recovery/queue-failed-customer-creation.test.ts @@ -0,0 +1,163 @@ +/** + * TDD contract for durable customer get-or-create failure capture. + * + * Contract under test: + * - Transient failures are written to a dedicated FIFO queue without API credentials. + * - Payloads preserve org, environment, API version, normalized request, stage, and request ID. + * - Identical recovery requests share a deterministic deduplication ID. + * - Every message uses one global message group so replay has a hard concurrency ceiling of one. + * - Missing or unavailable recovery infrastructure never replaces the original API failure. + */ + +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { ApiVersion, ApiVersionClass, AppEnv } from "@autumn/shared"; +import type { SQSClient } from "@aws-sdk/client-sqs"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { + CUSTOMER_CREATION_RECOVERY_MESSAGE_GROUP_ID, + queueFailedCustomerCreation, +} from "@/internal/customers/recovery/queueFailedCustomerCreation.js"; +import { getSqsClient } from "@/queue/initSqs.js"; + +const recoveryQueueUrl = + "https://sqs.us-east-2.amazonaws.com/123456789012/customer-creation-recovery.fifo"; + +const mockState = { + queueCommands: [] as Record[], + originalSend: null as SQSClient["send"] | null, + shouldFailSend: false, +}; + +const buildContext = () => + ({ + id: "req_customer_123", + org: { id: "org_123" }, + env: AppEnv.Live, + apiVersion: new ApiVersionClass(ApiVersion.V2_1), + extraLogs: {}, + logger: { + error: mock(() => {}), + warn: mock(() => {}), + }, + }) as unknown as AutumnContext; + +const params = { + customer_id: "customer_123", + customer_data: { + email: "customer@example.com", + name: "Customer", + }, + entity_id: "entity_123", + entity_data: { + name: "Entity", + feature_id: "seats", + }, +}; + +describe("queueFailedCustomerCreation", () => { + const originalQueueUrl = process.env.CUSTOMER_CREATION_RECOVERY_SQS_QUEUE_URL; + + beforeEach(() => { + mockState.queueCommands = []; + mockState.shouldFailSend = false; + process.env.CUSTOMER_CREATION_RECOVERY_SQS_QUEUE_URL = recoveryQueueUrl; + + const sqsClient = getSqsClient({ queueUrl: recoveryQueueUrl }); + mockState.originalSend = sqsClient.send.bind(sqsClient); + sqsClient.send = (async (command: { input: Record }) => { + mockState.queueCommands.push(command.input); + if (mockState.shouldFailSend) throw new Error("SQS unavailable"); + return {}; + }) as typeof sqsClient.send; + }); + + afterEach(() => { + if (mockState.originalSend) { + getSqsClient({ queueUrl: recoveryQueueUrl }).send = + mockState.originalSend; + } + process.env.CUSTOMER_CREATION_RECOVERY_SQS_QUEUE_URL = originalQueueUrl; + }); + + test("stores a replayable, serialized request with deterministic ordering and deduplication", async () => { + const firstContext = buildContext(); + const secondContext = buildContext(); + + const firstQueued = await queueFailedCustomerCreation({ + ctx: firstContext, + params, + source: "handleGetOrCreateCustomerV2", + withAutumnId: true, + failureStage: "lookup", + }); + const secondQueued = await queueFailedCustomerCreation({ + ctx: secondContext, + params, + source: "handleGetOrCreateCustomerV2", + withAutumnId: true, + failureStage: "lookup", + }); + + expect(firstQueued).toBe(true); + expect(secondQueued).toBe(true); + expect(mockState.queueCommands).toHaveLength(2); + expect(mockState.queueCommands[0]).toMatchObject({ + QueueUrl: recoveryQueueUrl, + MessageGroupId: CUSTOMER_CREATION_RECOVERY_MESSAGE_GROUP_ID, + }); + expect(mockState.queueCommands[0]?.MessageDeduplicationId).toBe( + mockState.queueCommands[1]?.MessageDeduplicationId, + ); + + const queuedMessage = JSON.parse( + mockState.queueCommands[0]?.MessageBody as string, + ); + expect(queuedMessage).toMatchObject({ + name: "customer-creation-recovery", + data: { + orgId: "org_123", + env: AppEnv.Live, + customerId: "customer_123", + requestId: "req_customer_123", + apiVersion: ApiVersion.V2_1, + params, + source: "handleGetOrCreateCustomerV2", + withAutumnId: true, + failureStage: "lookup", + }, + }); + expect(JSON.stringify(queuedMessage)).not.toContain("apiKey"); + expect(JSON.stringify(queuedMessage)).not.toContain("secretKey"); + }); + + test("returns false without masking the request when the queue is not configured", async () => { + process.env.CUSTOMER_CREATION_RECOVERY_SQS_QUEUE_URL = undefined; + const ctx = buildContext(); + + const queued = await queueFailedCustomerCreation({ + ctx, + params, + source: "handleGetOrCreateCustomerV2", + failureStage: "lookup", + }); + + expect(queued).toBe(false); + expect(mockState.queueCommands).toHaveLength(0); + expect(ctx.logger.error).toHaveBeenCalled(); + }); + + test("returns false without throwing when SQS is unavailable", async () => { + mockState.shouldFailSend = true; + const ctx = buildContext(); + + const queued = await queueFailedCustomerCreation({ + ctx, + params, + source: "handleGetOrCreateCustomerV2", + failureStage: "lookup", + }); + + expect(queued).toBe(false); + expect(ctx.logger.error).toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/customers/recovery/queue-rate-limited-customer-creation.test.ts b/server/tests/unit/customers/recovery/queue-rate-limited-customer-creation.test.ts new file mode 100644 index 0000000000..762c1e3299 --- /dev/null +++ b/server/tests/unit/customers/recovery/queue-rate-limited-customer-creation.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { ApiVersion, ApiVersionClass, AppEnv } from "@autumn/shared"; +import { Hono } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; + +const mockState = { + queueCalls: [] as Record[], +}; + +mock.module( + "@/internal/customers/recovery/queueFailedCustomerCreation.js", + () => ({ + queueFailedCustomerCreation: async (args: Record) => { + mockState.queueCalls.push(args); + return true; + }, + }), +); + +const { queueRateLimitedCustomerCreation } = await import( + // @ts-expect-error - Bun test cache-busting import query isolates module mocks. + "@/internal/customers/recovery/queueRateLimitedCustomerCreation.js?rateLimitRecovery" +); + +const buildApp = () => { + const app = new Hono(); + app.use("*", async (c, next) => { + const requestBody = await c.req.json(); + c.set("ctx", { + id: "req_rate_limited_123", + org: { id: "org_123" }, + env: AppEnv.Live, + apiVersion: new ApiVersionClass(ApiVersion.V2_1), + extraLogs: {}, + logger: { + error: mock(() => {}), + }, + requestBody, + } as never); + await next(); + }); + app.post("*", async (c) => + c.json({ + queued: await queueRateLimitedCustomerCreation({ c }), + }), + ); + return app; +}; + +describe("queueRateLimitedCustomerCreation", () => { + beforeEach(() => { + mockState.queueCalls = []; + }); + + test("captures a validated customers.get_or_create request", async () => { + const app = buildApp(); + const response = await app.request( + "http://localhost/v1/customers.get_or_create", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + customer_id: "customer_123", + email: "customer@example.com", + entity_id: "entity_123", + entity_data: { name: "Workspace", feature_id: "seats" }, + with_autumn_id: true, + }), + }, + ); + + expect(await response.json()).toEqual({ queued: true }); + expect(mockState.queueCalls).toEqual([ + expect.objectContaining({ + params: { + customer_id: "customer_123", + customer_data: { + email: "customer@example.com", + }, + entity_id: "entity_123", + entity_data: { name: "Workspace", feature_id: "seats" }, + }, + source: "rateLimit:customers.get_or_create", + withAutumnId: true, + failureStage: "lookup", + }), + ]); + }); + + test("captures a validated legacy customer create request", async () => { + const app = buildApp(); + const response = await app.request( + "http://localhost/v1/customers?with_autumn_id=true", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "customer_legacy", + email: "legacy@example.com", + }), + }, + ); + + expect(await response.json()).toEqual({ queued: true }); + expect(mockState.queueCalls).toEqual([ + expect.objectContaining({ + params: { + customer_id: "customer_legacy", + customer_data: { + email: "legacy@example.com", + }, + }, + source: "rateLimit:customers", + withAutumnId: true, + failureStage: "lookup", + }), + ]); + }); + + test("does not queue invalid or unrelated requests", async () => { + const app = buildApp(); + const invalidResponse = await app.request( + "http://localhost/v1/customers.get_or_create", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ customer_id: null }), + }, + ); + const fetchResponse = await app.request( + "http://localhost/v1/customers.get", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ customer_id: "customer_123" }), + }, + ); + + expect(await invalidResponse.json()).toEqual({ queued: false }); + expect(await fetchResponse.json()).toEqual({ queued: false }); + expect(mockState.queueCalls).toHaveLength(0); + }); +}); diff --git a/server/tests/unit/customers/recovery/replay-failed-customer-creation.test.ts b/server/tests/unit/customers/recovery/replay-failed-customer-creation.test.ts new file mode 100644 index 0000000000..2a4c5ac20f --- /dev/null +++ b/server/tests/unit/customers/recovery/replay-failed-customer-creation.test.ts @@ -0,0 +1,125 @@ +/** + * TDD contract for customer creation recovery replay. + * + * Contract under test: + * - Safe failures replay the original normalized get-or-create operation. + * - Replays preserve the original API version and cannot enqueue themselves again. + * - Replays log whether they created or fetched the customer. + * - Failures after the Autumn commit stop for manual billing review. + */ + +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { ApiVersion, ApiVersionClass, AppEnv } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { CustomerCreationRecoveryPayload } from "@/internal/customers/recovery/customerCreationRecoveryTypes.js"; + +const mockState = { + getOrCreateCalls: [] as Record[], + outcome: "created" as "created" | "existing" | undefined, +}; + +mock.module( + "@/internal/customers/actions/getOrCreateApiCustomerByRollout.js", + () => ({ + getOrCreateApiCustomerByRollout: async ( + args: Record & { ctx: AutumnContext }, + ) => { + mockState.getOrCreateCalls.push(args); + if (mockState.outcome) { + args.ctx.extraLogs.autumnPlanResult = mockState.outcome; + } + return { id: "customer_123" }; + }, + }), +); + +const { replayFailedCustomerCreation } = await import( + // @ts-expect-error - Bun test cache-busting import query isolates module mocks. + "@/internal/customers/recovery/replayFailedCustomerCreation.js?customerCreationRecovery" +); + +const buildContext = () => + ({ + org: { id: "org_123" }, + env: AppEnv.Live, + apiVersion: new ApiVersionClass(ApiVersion.V0_2), + extraLogs: {}, + logger: { + info: mock(() => {}), + error: mock(() => {}), + }, + }) as unknown as AutumnContext; + +const buildPayload = ( + failureStage: CustomerCreationRecoveryPayload["failureStage"] = "lookup", +): CustomerCreationRecoveryPayload => ({ + orgId: "org_123", + env: AppEnv.Live, + customerId: "customer_123", + requestId: "req_customer_123", + apiVersion: ApiVersion.V2_1, + params: { + customer_id: "customer_123", + customer_data: { email: "customer@example.com" }, + }, + source: "handleGetOrCreateCustomerV2", + withAutumnId: true, + failureStage, + failedAt: 1_785_000_000_000, +}); + +describe("replayFailedCustomerCreation", () => { + beforeEach(() => { + mockState.getOrCreateCalls = []; + mockState.outcome = "created"; + }); + + test("replays a safe request with its original API semantics", async () => { + const ctx = buildContext(); + const payload = buildPayload(); + + await replayFailedCustomerCreation({ ctx, payload }); + + expect(ctx.apiVersion.value).toBe(ApiVersion.V2_1); + expect(mockState.getOrCreateCalls).toEqual([ + expect.objectContaining({ + ctx, + params: payload.params, + source: "customerCreationRecovery", + withAutumnId: true, + enqueueRecoveryOnTransientFailure: false, + }), + ]); + expect(ctx.extraLogs.customerCreationRecoveryReplay).toMatchObject({ + outcome: "created", + sourceRequestId: "req_customer_123", + }); + }); + + test("records a fetch when replay finds an existing customer", async () => { + mockState.outcome = undefined; + const ctx = buildContext(); + + await replayFailedCustomerCreation({ + ctx, + payload: buildPayload("completed"), + }); + + expect(ctx.extraLogs.customerCreationRecoveryReplay).toMatchObject({ + outcome: "fetched", + }); + }); + + test("refuses automatic replay after the Autumn transaction committed", async () => { + const ctx = buildContext(); + + await expect( + replayFailedCustomerCreation({ + ctx, + payload: buildPayload("autumn_committed"), + }), + ).rejects.toThrow("requires manual billing review"); + + expect(mockState.getOrCreateCalls).toHaveLength(0); + }); +}); diff --git a/server/tests/unit/customers/reset/batch-reset-customer-entitlements-concurrency.test.ts b/server/tests/unit/customers/reset/batch-reset-customer-entitlements-concurrency.test.ts new file mode 100644 index 0000000000..9e6178d75d --- /dev/null +++ b/server/tests/unit/customers/reset/batch-reset-customer-entitlements-concurrency.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import type { FullCustomer } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { batchResetCustomerEntitlements } from "@/internal/customers/actions/resetCustomerEntitlements/batchResetCustomerEntitlements.js"; +import { CusService } from "@/internal/customers/CusService.js"; + +describe("batchResetCustomerEntitlements", () => { + test("limits concurrent customer rehydrations to five", async () => { + let activeRehydrations = 0; + let maximumConcurrentRehydrations = 0; + + const getFullSpy = spyOn(CusService, "getFull").mockImplementation( + async () => { + activeRehydrations++; + maximumConcurrentRehydrations = Math.max( + maximumConcurrentRehydrations, + activeRehydrations, + ); + + await new Promise((resolve) => setTimeout(resolve, 5)); + activeRehydrations--; + return {} as FullCustomer; + }, + ); + + try { + await batchResetCustomerEntitlements({ + ctx: {} as AutumnContext, + payload: { + orgId: "org_test", + env: "sandbox", + resets: Array.from({ length: 20 }, (_, index) => ({ + internalCustomerId: `internal_customer_${index}`, + customerId: `customer_${index}`, + cusEntIds: [`customer_entitlement_${index}`], + })), + }, + }); + + expect(getFullSpy).toHaveBeenCalledTimes(20); + expect(maximumConcurrentRehydrations).toBe(5); + } finally { + getFullSpy.mockRestore(); + } + }); +}); diff --git a/server/tests/unit/db/shed503OnTransientError.test.ts b/server/tests/unit/db/shed503OnTransientError.test.ts new file mode 100644 index 0000000000..46e0c628f0 --- /dev/null +++ b/server/tests/unit/db/shed503OnTransientError.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, mock, test } from "bun:test"; +import { shed503OnTransientError } from "@/db/shed503OnTransientError.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; + +const buildContext = () => + ({ + logger: { + warn: mock(() => {}), + error: mock(() => {}), + }, + }) as unknown as AutumnContext; + +const transientError = Object.assign(new Error("connect timeout"), { + code: "CONNECT_TIMEOUT", +}); + +describe("shed503OnTransientError", () => { + test("runs transient failure capture before returning the overload 503", async () => { + const ctx = buildContext(); + const onTransientError = mock(async () => {}); + + await expect( + shed503OnTransientError({ + ctx, + source: "get_or_create", + run: () => { + throw transientError; + }, + onTransientError, + }), + ).rejects.toMatchObject({ + statusCode: 503, + data: { reason: "critical_db_saturated" }, + }); + + expect(onTransientError).toHaveBeenCalledWith(transientError); + }); + + test("does not let recovery queue failure replace the overload response", async () => { + const ctx = buildContext(); + + await expect( + shed503OnTransientError({ + ctx, + source: "get_or_create", + run: () => { + throw transientError; + }, + onTransientError: async () => { + throw new Error("SQS unavailable"); + }, + }), + ).rejects.toMatchObject({ + statusCode: 503, + data: { reason: "critical_db_saturated" }, + }); + + expect(ctx.logger.error).toHaveBeenCalled(); + }); + + test("does not capture non-transient application errors", async () => { + const ctx = buildContext(); + const applicationError = new Error("invalid customer"); + const onTransientError = mock(async () => {}); + + await expect( + shed503OnTransientError({ + ctx, + source: "get_or_create", + run: () => { + throw applicationError; + }, + onTransientError, + }), + ).rejects.toBe(applicationError); + + expect(onTransientError).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/edge-config/reset-job-config.test.ts b/server/tests/unit/edge-config/reset-job-config.test.ts new file mode 100644 index 0000000000..0856f640fc --- /dev/null +++ b/server/tests/unit/edge-config/reset-job-config.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { ResetJobConfigSchema } from "@/internal/misc/resetJob/resetJobSchemas.js"; +import { + getResetJobConfig, + isResetJobEnabled, + setResetJobConfigForTesting, +} from "@/internal/misc/resetJob/resetJobStore.js"; + +describe("reset job config", () => { + afterEach(() => { + setResetJobConfigForTesting({ + config: { enabled: false, batchSize: 500 }, + }); + }); + + test("defaults to disabled", () => { + expect(ResetJobConfigSchema.parse({})).toEqual({ + enabled: false, + batchSize: 500, + }); + expect(isResetJobEnabled()).toBe(false); + }); + + test("updates the job at runtime", () => { + setResetJobConfigForTesting({ + config: { enabled: true, batchSize: 1_000 }, + }); + expect(isResetJobEnabled()).toBe(true); + expect(getResetJobConfig().batchSize).toBe(1_000); + }); + + test("bounds the batch size", () => { + expect(() => + ResetJobConfigSchema.parse({ enabled: true, batchSize: 0 }), + ).toThrow(); + expect(() => + ResetJobConfigSchema.parse({ enabled: true, batchSize: 2_001 }), + ).toThrow(); + }); +}); diff --git a/server/tests/unit/edge-config/reset-job-v2-config.test.ts b/server/tests/unit/edge-config/reset-job-v2-config.test.ts new file mode 100644 index 0000000000..5329c6c85f --- /dev/null +++ b/server/tests/unit/edge-config/reset-job-v2-config.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { ResetJobV2ConfigSchema } from "@/internal/misc/resetJobV2/resetJobV2Schemas.js"; +import { + getResetJobV2Config, + setResetJobV2ConfigForTesting, +} from "@/internal/misc/resetJobV2/resetJobV2Store.js"; + +const defaultConfig = ResetJobV2ConfigSchema.parse({}); + +describe("reset job V2 config", () => { + afterEach(() => { + setResetJobV2ConfigForTesting({ config: defaultConfig }); + }); + + test("defaults to three concurrent reset jobs", () => { + expect(defaultConfig.maxConcurrentJobs).toBe(3); + }); + + test("applies the concurrency limit at runtime", () => { + setResetJobV2ConfigForTesting({ + config: { ...defaultConfig, maxConcurrentJobs: 7 }, + }); + + expect(getResetJobV2Config().maxConcurrentJobs).toBe(7); + }); + + test("bounds concurrent reset jobs", () => { + expect(() => + ResetJobV2ConfigSchema.parse({ maxConcurrentJobs: 0 }), + ).toThrow(); + expect(() => + ResetJobV2ConfigSchema.parse({ maxConcurrentJobs: 101 }), + ).toThrow(); + }); +}); diff --git a/server/tests/unit/edge-config/worker-edge-config-registration.test.ts b/server/tests/unit/edge-config/worker-edge-config-registration.test.ts new file mode 100644 index 0000000000..f4cb607b85 --- /dev/null +++ b/server/tests/unit/edge-config/worker-edge-config-registration.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "bun:test"; +import path from "node:path"; + +test("workers register miscellaneous edge config before polling starts", async () => { + const workersPath = path.resolve(import.meta.dir, "../../../src/workers.ts"); + const workersSource = await Bun.file(workersPath).text(); + const storeImport = + 'import "./internal/misc/miscellaneousEdgeConfig/miscellaneousEdgeConfigStore.js";'; + const storeImportIndex = workersSource.indexOf(storeImport); + const pollingStartIndex = workersSource.indexOf( + "await startAllEdgeConfigPolling({ logger });", + ); + + expect(storeImportIndex).toBeGreaterThanOrEqual(0); + expect(pollingStartIndex).toBeGreaterThan(storeImportIndex); +}); diff --git a/server/tests/unit/honoUtils/handle-health-check.test.ts b/server/tests/unit/honoUtils/handle-health-check.test.ts index 753bc5a9d4..eb23371ba3 100644 --- a/server/tests/unit/honoUtils/handle-health-check.test.ts +++ b/server/tests/unit/honoUtils/handle-health-check.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, mock, test } from "bun:test"; +import { afterAll, describe, expect, mock, test } from "bun:test"; import { EventEmitter } from "node:events"; class FakeRedis extends EventEmitter { @@ -20,6 +20,12 @@ const fakeLogger = { child: mock(() => fakeLogger), }; +// Real logger captured BEFORE mocking so afterAll can restore it — module +// mocks leak across test files (mock.restore does not undo them). +const realLogtailUtils = { + ...(await import("@/external/logtail/logtailUtils.js")), +}; + mock.module("@/external/redis/initRedis.js", () => ({ redis: fakeRedis, hasRedisConfig: true, @@ -34,6 +40,15 @@ mock.module("@/external/logtail/logtailUtils.js", () => ({ const { handleHealthCheck } = await import("@/honoUtils/handleHealthCheck.js"); +afterAll(() => { + mock.module("@/external/logtail/logtailUtils.js", () => realLogtailUtils); + // Redis modules stay mocked (importing the real ones would open live + // connections, which unit tests must never do) — but reset the fakes so + // later files see "not ready" fail-open instead of this file's end state. + fakeRedis.status = "wait"; + fakeRedisV2.status = "wait"; +}); + const callHealthCheck = async () => { const responses: { status: number; body: string }[] = []; const ctx = { diff --git a/server/tests/unit/logging/firelens-config.test.ts b/server/tests/unit/logging/firelens-config.test.ts new file mode 100644 index 0000000000..fae8254816 --- /dev/null +++ b/server/tests/unit/logging/firelens-config.test.ts @@ -0,0 +1,35 @@ +/** + * Contract: FireLens parses Pino's JSON envelope, sends structured records to + * `express`, keeps console records in `ecs`, and bounds retries and re-emission. + */ + +import { expect, test } from "bun:test"; +import path from "node:path"; + +test.concurrent( + "FireLens separates structured and console log datasets", + async () => { + const configPath = path.resolve( + import.meta.dir, + "../../../../firelens.conf", + ); + const config = await Bun.file(configPath).text(); + + expect(config).toContain("Parsers_File /fluent-bit/etc/parsers.conf"); + expect(config).not.toContain("[PARSER]"); + expect(config).toContain("Key_Name log"); + expect(config).toContain("Parser json"); + expect(config).toContain("Reserve_Data On"); + expect(config).toContain( + "Rule $level ^(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)$ axiom_express false", + ); + expect(config).toContain("Emitter_Mem_Buf_Limit 10M"); + expect(config).toMatch( + /\[FILTER\]\s+Name modify\s+Match axiom_express\s+Remove time\s+Remove region\s+Remove container_id\s+Remove container_name/, + ); + expect(config).toContain("URI /v1/ingest/express"); + expect(config).toContain("URI /v1/ingest/ecs"); + expect(config.match(/retry_limit 5/g)).toHaveLength(2); + expect(config).not.toMatch(/\[OUTPUT\]\s+Name http\s+Match \*(?:\s|$)/); + }, +); diff --git a/server/tests/unit/migrations-v2/filters/iterate-over-filter-results.test.ts b/server/tests/unit/migrations-v2/filters/iterate-over-filter-results.test.ts new file mode 100644 index 0000000000..13b15128b4 --- /dev/null +++ b/server/tests/unit/migrations-v2/filters/iterate-over-filter-results.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test"; +import { sql } from "drizzle-orm"; +import { iterateOverFilterResults } from "@/internal/migrations/v2/filters/iterateOverFilterResults.js"; + +test("filter iteration resumes after a cross-task customer cursor", async () => { + const cursors: Array = []; + const responses = [ + [{ internal_id: "customer_101" }, { internal_id: "customer_102" }], + [{ internal_id: "customer_103" }], + ]; + const rows: string[] = []; + + const source = iterateOverFilterResults({ + db: { + execute: async () => responses.shift() ?? [], + }, + buildSelect: ({ afterInternalId }) => { + cursors.push(afterInternalId); + return sql`select 1`; + }, + batchSize: 2, + afterInternalId: "customer_100", + }); + + for await (const batch of source) { + rows.push(...batch.map((row) => row.internal_id)); + } + + expect(cursors).toEqual(["customer_100", "customer_102"]); + expect(rows).toEqual(["customer_101", "customer_102", "customer_103"]); +}); diff --git a/server/tests/unit/migrations-v2/scheduling/iterate-scope-scheduling.test.ts b/server/tests/unit/migrations-v2/scheduling/iterate-scope-scheduling.test.ts new file mode 100644 index 0000000000..e814290989 --- /dev/null +++ b/server/tests/unit/migrations-v2/scheduling/iterate-scope-scheduling.test.ts @@ -0,0 +1,123 @@ +// Contract: scheduled scopes run one customer at a time and finish the task only between customers. +// Exhausting the source on the final customer must not request an unnecessary continuation. +import { describe, expect, test } from "bun:test"; +import { iterateScope } from "@/internal/migrations/v2/run/orchestrators/iterateScope.js"; +import type { MigrationRunScheduler } from "@/internal/migrations/v2/run/types/migrationRunScheduler.js"; +import type { RunScopeItem } from "@/internal/migrations/v2/run/types/runScope.js"; + +const customerItem = (id: string): RunScopeItem => ({ + kind: "customer", + internal_id: `internal_${id}`, + id, +}); + +const iterateItems = (items: RunScopeItem[]) => + async function* iterate(): AsyncGenerator { + yield items; + }; + +describe("iterateScope migration scheduling", () => { + test("forces sequential customer execution when a scheduler is present", async () => { + let active = 0; + let maxActive = 0; + const scheduler: MigrationRunScheduler = { + batchSize: 100, + sliceDurationMs: Number.POSITIVE_INFINITY, + now: () => 0, + }; + + await iterateScope({ + iterate: iterateItems([ + customerItem("customer_1"), + customerItem("customer_2"), + customerItem("customer_3"), + ]), + concurrency: 3, + scheduler, + perItem: async () => { + active++; + maxActive = Math.max(maxActive, active); + await Promise.resolve(); + active--; + return undefined; + }, + }); + + expect(maxActive).toBe(1); + }); + + test("ends a slice between customers after the time budget", async () => { + let nowMs = 0; + const events: string[] = []; + const scheduler: MigrationRunScheduler = { + batchSize: 100, + sliceDurationMs: 10, + now: () => nowMs, + }; + + const summary = await iterateScope({ + iterate: iterateItems([ + customerItem("customer_1"), + customerItem("customer_2"), + customerItem("customer_3"), + ]), + scheduler, + perItem: async (item) => { + events.push(item.id ?? item.internal_id); + nowMs += 6; + return undefined; + }, + }); + + expect(events).toEqual(["customer_1", "customer_2"]); + expect(summary.completion).toBe("slice_complete"); + expect(summary.processed).toBe(2); + expect(summary.cursor).toBe("internal_customer_2"); + }); + + test("reports exhaustion instead of continuing after the final customer", async () => { + let nowMs = 0; + const scheduler: MigrationRunScheduler = { + batchSize: 100, + sliceDurationMs: 10, + now: () => nowMs, + }; + + const summary = await iterateScope({ + iterate: iterateItems([ + customerItem("customer_1"), + customerItem("customer_2"), + ]), + scheduler, + perItem: async () => { + nowMs += 6; + return undefined; + }, + }); + + expect(summary.completion).toBe("exhausted"); + expect(summary.processed).toBe(2); + expect(summary.cursor).toBe("internal_customer_2"); + }); + + test("stops source consumption when the caller requests cancellation", async () => { + let stopRequested = false; + const processed: string[] = []; + + const summary = await iterateScope({ + iterate: iterateItems([ + customerItem("customer_1"), + customerItem("customer_2"), + ]), + shouldStop: () => stopRequested, + perItem: async (item) => { + processed.push(item.id ?? item.internal_id); + stopRequested = true; + return undefined; + }, + }); + + expect(processed).toEqual(["customer_1"]); + expect(summary.completion).toBe("stopped"); + }); +}); diff --git a/server/tests/unit/migrations-v2/scheduling/migration-task-scheduler.test.ts b/server/tests/unit/migrations-v2/scheduling/migration-task-scheduler.test.ts new file mode 100644 index 0000000000..c7710e48a3 --- /dev/null +++ b/server/tests/unit/migrations-v2/scheduling/migration-task-scheduler.test.ts @@ -0,0 +1,60 @@ +// Contract: finite customer tasks share one queue; coordinators do not sleep while holding its only slot. +import { describe, expect, test } from "bun:test"; +import { + createMigrationChunkScheduler, + getMigrationTriggerOptions, + MIGRATION_CHUNK_FETCH_SIZE, + MIGRATION_CHUNK_MAX_DURATION_SECONDS, + MIGRATION_LAZY_TASK_PRIORITY_SECONDS, + MIGRATION_RUN_CUSTOMER_CONCURRENCY, + MIGRATION_SLICE_DURATION_MS, + MIGRATION_TASK_QUEUE_CONCURRENCY, + MIGRATION_TASK_RETRY, + migrationTaskQueue, +} from "@/trigger/migrations/migrationTaskQueue.js"; + +describe("migration task scheduler", () => { + test("defines one fleet-wide queue with a conservative initial limit", () => { + expect(migrationTaskQueue.name).toBe("migration-customer-work"); + expect(migrationTaskQueue.concurrencyLimit).toBe( + MIGRATION_TASK_QUEUE_CONCURRENCY, + ); + expect(MIGRATION_TASK_QUEUE_CONCURRENCY).toBe(1); + }); + + test("keeps fleet and per-run concurrency independently tunable", () => { + expect(MIGRATION_TASK_QUEUE_CONCURRENCY).toBe(1); + expect(MIGRATION_RUN_CUSTOMER_CONCURRENCY).toBe(1); + }); + + test("uses a bounded customer-work slice", () => { + expect(MIGRATION_SLICE_DURATION_MS).toBe(10_000); + expect(MIGRATION_CHUNK_FETCH_SIZE).toBe(100); + }); + + test("does not automatically retry checkpointed migration tasks", () => { + expect(MIGRATION_TASK_RETRY).toEqual({ maxAttempts: 1 }); + }); + + test("bounds a stuck chunk and prioritizes request-path customer work", () => { + expect(MIGRATION_CHUNK_MAX_DURATION_SECONDS).toBe(15 * 60); + expect(MIGRATION_LAZY_TASK_PRIORITY_SECONDS).toBeGreaterThan( + MIGRATION_SLICE_DURATION_MS / 1000, + ); + }); + + test("does not partition migration runs into per-org queues", () => { + expect(getMigrationTriggerOptions({ isDev: false })).toEqual({}); + expect(getMigrationTriggerOptions({ isDev: true })).toEqual({ + region: "eu-central-1", + }); + }); + + test("creates a pure clock-based scheduler with no in-task wait", () => { + const scheduler = createMigrationChunkScheduler({ now: () => 123 }); + + expect(scheduler.sliceDurationMs).toBe(MIGRATION_SLICE_DURATION_MS); + expect(scheduler.batchSize).toBe(MIGRATION_CHUNK_FETCH_SIZE); + expect(scheduler.now()).toBe(123); + }); +}); diff --git a/server/tests/unit/migrations-v2/scheduling/run-migration-in-chunks.test.ts b/server/tests/unit/migrations-v2/scheduling/run-migration-in-chunks.test.ts new file mode 100644 index 0000000000..bf10d9d727 --- /dev/null +++ b/server/tests/unit/migrations-v2/scheduling/run-migration-in-chunks.test.ts @@ -0,0 +1,160 @@ +/** + * Contract: a migration coordinator submits finite customer slices until the source is exhausted. + * Total limits and cancellation apply across slices, and zero-progress slices cannot spin forever. + */ + +import { describe, expect, test } from "bun:test"; +import { AppEnv } from "@autumn/shared"; +import { RunMigrationChunkPayloadSchema } from "@/trigger/migrations/migrationTaskPayload.js"; +import { runMigrationInChunks } from "@/trigger/migrations/runMigrationInChunks.js"; + +describe("runMigrationInChunks", () => { + test("requires a frozen prepared migration snapshot for every chunk", () => { + const migration = { + internal_id: "migration_internal_id", + id: "migration_id", + org_id: "org_id", + env: AppEnv.Sandbox, + filter: { customer: { customer_id: { $in: ["customer_1"] } } }, + operations: { + customer: [{ type: "add_plan" as const, plan_id: "plan_id" }], + }, + prepared_state: {}, + no_billing_changes: true, + retry_failed: false, + archived: false, + created_at: 1, + updated_at: null, + event_internal_id: "migration_internal_id", + }; + + const result = RunMigrationChunkPayloadSchema.parse({ + orgId: "org_id", + env: AppEnv.Sandbox, + migrationId: "migration_id", + migrationRunId: "migration_run_id", + dryRun: false, + lazyRun: false, + chunkIndex: 0, + migration, + }); + + expect(result.migration).toEqual(migration); + expect("preparedState" in result).toBe(false); + }); + + test("submits continuations until a chunk exhausts the source", async () => { + const completions = [ + { + processed: 2, + completion: "slice_complete" as const, + cursor: "customer_2", + }, + { + processed: 1, + completion: "slice_complete" as const, + cursor: "customer_3", + }, + { processed: 0, completion: "exhausted" as const, cursor: null }, + ]; + const limits: Array = []; + const cursors: Array = []; + + const result = await runMigrationInChunks({ + isCancelRequested: async () => false, + runChunk: async ({ limit, cursor }) => { + limits.push(limit); + cursors.push(cursor); + const next = completions.shift(); + if (!next) throw new Error("missing test completion"); + return next; + }, + }); + + expect(limits).toEqual([undefined, undefined, undefined]); + expect(cursors).toEqual([undefined, "customer_2", "customer_3"]); + expect(result).toEqual({ processed: 3, chunks: 3, canceled: false }); + }); + + test("carries the remaining total limit into each continuation", async () => { + const limits: Array = []; + + const result = await runMigrationInChunks({ + limit: 3, + isCancelRequested: async () => false, + runChunk: async ({ limit, chunkIndex }) => { + limits.push(limit); + return { + processed: limit === 3 ? 2 : 1, + completion: "slice_complete", + cursor: `customer_${chunkIndex + 1}`, + }; + }, + }); + + expect(limits).toEqual([3, 1]); + expect(result).toEqual({ processed: 3, chunks: 2, canceled: false }); + }); + + test("does not submit another chunk after cancellation", async () => { + let chunkCount = 0; + let cancelChecks = 0; + + const result = await runMigrationInChunks({ + isCancelRequested: async () => { + cancelChecks++; + return cancelChecks > 1; + }, + runChunk: async () => { + chunkCount++; + return { + processed: 2, + completion: "slice_complete", + cursor: "customer_2", + }; + }, + }); + + expect(chunkCount).toBe(1); + expect(result).toEqual({ processed: 2, chunks: 1, canceled: true }); + }); + + test("honors cancellation detected inside a chunk", async () => { + const result = await runMigrationInChunks({ + isCancelRequested: async () => false, + runChunk: async () => ({ + processed: 1, + completion: "stopped", + cursor: "customer_1", + }), + }); + + expect(result).toEqual({ processed: 1, chunks: 1, canceled: true }); + }); + + test("rejects a continuation that made no progress", async () => { + expect( + runMigrationInChunks({ + isCancelRequested: async () => false, + runChunk: async () => ({ + processed: 0, + completion: "slice_complete", + cursor: null, + }), + }), + ).rejects.toThrow("made no progress"); + }); + + test("rejects a continuation without a stable cursor", async () => { + expect( + runMigrationInChunks({ + isCancelRequested: async () => false, + runChunk: async () => ({ + processed: 1, + completion: "slice_complete", + cursor: null, + }), + }), + ).rejects.toThrow("did not return a cursor"); + }); +}); diff --git a/server/tests/unit/misc/batchReset/batchResetConfigStore.test.ts b/server/tests/unit/misc/batchReset/batchResetConfigStore.test.ts new file mode 100644 index 0000000000..188b9ba930 --- /dev/null +++ b/server/tests/unit/misc/batchReset/batchResetConfigStore.test.ts @@ -0,0 +1,49 @@ +import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; + +// Capture the real module BEFORE mocking; restored in afterAll (module mocks +// leak across test files — mock.restore does not undo them). +const realEdgeConfigRegistry = { + ...(await import("@/internal/misc/edgeConfig/edgeConfigRegistry.js")), +}; + +mock.module("@/internal/misc/edgeConfig/edgeConfigRegistry.js", () => ({ + registerEdgeConfig: () => undefined, +})); + +import { BatchResetConfigSchema } from "@/internal/misc/batchReset/batchResetConfigSchemas.js"; +import { + _setBatchResetConfigForTesting, + isBatchResetEnabled, +} from "@/internal/misc/batchReset/batchResetConfigStore.js"; + +const reset = () => { + _setBatchResetConfigForTesting({ config: { enabled: true } }); +}; + +describe("isBatchResetEnabled", () => { + afterEach(reset); + + test("defaults to enabled when the S3 config is absent", () => { + expect(BatchResetConfigSchema.parse({})).toEqual({ enabled: true }); + }); + + test("returns true when batch resets are enabled", () => { + reset(); + + expect(isBatchResetEnabled()).toBe(true); + }); + + test("returns false when batch resets are disabled", () => { + _setBatchResetConfigForTesting({ config: { enabled: false } }); + + expect(isBatchResetEnabled()).toBe(false); + }); +}); + +afterAll(() => { + mock.module( + "@/internal/misc/edgeConfig/edgeConfigRegistry.js", + () => realEdgeConfigRegistry, + ); + mock.restore(); +}); diff --git a/server/tests/unit/misc/batchReset/batchResetWorkflowGate.test.ts b/server/tests/unit/misc/batchReset/batchResetWorkflowGate.test.ts new file mode 100644 index 0000000000..7421c20475 --- /dev/null +++ b/server/tests/unit/misc/batchReset/batchResetWorkflowGate.test.ts @@ -0,0 +1,63 @@ +import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; + +// Capture real modules BEFORE mocking — mock.module leaks across test files +// (mock.restore does not undo it), so afterAll re-mocks with the real exports. +const realEdgeConfigRegistry = { + ...(await import("@/internal/misc/edgeConfig/edgeConfigRegistry.js")), +}; +const realQueueUtils = { ...(await import("@/queue/queueUtils.js")) }; + +mock.module("@/internal/misc/edgeConfig/edgeConfigRegistry.js", () => ({ + registerEdgeConfig: () => undefined, +})); + +const queuedJobs: Record[] = []; +mock.module("@/queue/queueUtils.js", () => ({ + addTaskToQueue: async (args: Record) => { + queuedJobs.push(args); + }, +})); + +const { _setBatchResetConfigForTesting } = await import( + "@/internal/misc/batchReset/batchResetConfigStore.js" +); +const { workflows } = await import("@/queue/workflows.js"); + +const payload = { + orgId: "org_test", + env: "sandbox", + resets: [], +}; + +const reset = () => { + queuedJobs.length = 0; + _setBatchResetConfigForTesting({ config: { enabled: true } }); +}; + +describe("batch reset workflow edge config", () => { + afterEach(reset); + + test("does not enqueue batch reset jobs when disabled", async () => { + _setBatchResetConfigForTesting({ config: { enabled: false } }); + + await workflows.triggerBatchResetCusEnts(payload); + + expect(queuedJobs).toHaveLength(0); + }); + + test("enqueues batch reset jobs when enabled", async () => { + await workflows.triggerBatchResetCusEnts(payload); + + expect(queuedJobs).toHaveLength(1); + expect(queuedJobs[0]?.jobName).toBe("batch-reset-cus-ents"); + }); +}); + +afterAll(() => { + mock.module( + "@/internal/misc/edgeConfig/edgeConfigRegistry.js", + () => realEdgeConfigRegistry, + ); + mock.module("@/queue/queueUtils.js", () => realQueueUtils); + mock.restore(); +}); diff --git a/server/tests/unit/misc/rateLimiter/rateLimitRedisAllowlistStore.test.ts b/server/tests/unit/misc/rateLimiter/rateLimitRedisAllowlistStore.test.ts index 41ac664b54..5669d52247 100644 --- a/server/tests/unit/misc/rateLimiter/rateLimitRedisAllowlistStore.test.ts +++ b/server/tests/unit/misc/rateLimiter/rateLimitRedisAllowlistStore.test.ts @@ -1,5 +1,11 @@ import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; +// Capture the real module BEFORE mocking; restored in afterAll (module mocks +// leak across test files — mock.restore does not undo them). +const realEdgeConfigRegistry = { + ...(await import("@/internal/misc/edgeConfig/edgeConfigRegistry.js")), +}; + mock.module("@/internal/misc/edgeConfig/edgeConfigRegistry.js", () => ({ registerEdgeConfig: () => undefined, })); @@ -69,5 +75,9 @@ describe("isCustomerInRedisAllowlist", () => { }); afterAll(() => { + mock.module( + "@/internal/misc/edgeConfig/edgeConfigRegistry.js", + () => realEdgeConfigRegistry, + ); mock.restore(); }); diff --git a/server/tests/unit/queue/processMessage.test.ts b/server/tests/unit/queue/processMessage.test.ts index 0048bebd7b..ec2ce0b634 100644 --- a/server/tests/unit/queue/processMessage.test.ts +++ b/server/tests/unit/queue/processMessage.test.ts @@ -4,6 +4,38 @@ import { JobName } from "@/queue/JobName.js"; import { shouldRetrySqsJobError } from "@/queue/processMessage.js"; describe("shouldRetrySqsJobError", () => { + test("does not retry permanent customer creation recovery failures", () => { + expect( + shouldRetrySqsJobError({ + jobName: JobName.CustomerCreationRecovery, + error: new Error("requires manual billing review"), + }), + ).toBe(false); + }); + + test("retries customer creation recovery on transient database errors", () => { + expect( + shouldRetrySqsJobError({ + jobName: JobName.CustomerCreationRecovery, + error: Object.assign(new Error("connect timeout"), { + code: "CONNECT_TIMEOUT", + }), + }), + ).toBe(true); + }); + + test("retries customer creation recovery on transient Redis errors", () => { + expect( + shouldRetrySqsJobError({ + jobName: JobName.CustomerCreationRecovery, + error: new RedisUnavailableError({ + source: "customerCreationRecovery", + reason: "timeout", + }), + }), + ).toBe(true); + }); + test("retries track jobs on transient Redis errors", () => { expect( shouldRetrySqsJobError({ diff --git a/server/tests/unit/rate-limits/rate-limit-factory.test.ts b/server/tests/unit/rate-limits/rate-limit-factory.test.ts index 4c79877c36..701d60fce5 100644 --- a/server/tests/unit/rate-limits/rate-limit-factory.test.ts +++ b/server/tests/unit/rate-limits/rate-limit-factory.test.ts @@ -1,4 +1,6 @@ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { Hono } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; const mockState = { shouldUseRedis: false, @@ -60,6 +62,46 @@ describe("rateLimitFactory", () => { "[rate-limit] Redis unavailable; bypassing distributed rate limiting", ]); }); + + test("returns 429 without Retry-After for an over-limit establish route", async () => { + const app = new Hono(); + const middleware = rateLimitFactory({ + type: RateLimitType.CheckOrg, + config: { + name: "test-check-org", + limit: 1, + windowMs: 60_000, + notInRedis: true, + scope: RateLimitScope.Org, + overLimit: "degrade", + }, + }); + + app.use("*", async (c, next) => { + c.set("ctx", { + env: "live", + org: { id: "org_123", slug: "test-org" }, + } as never); + return middleware(c as never, next); + }); + app.post("/v1/customers", (c) => c.json({ success: true })); + + const firstResponse = await app.request("/v1/customers", { + method: "POST", + }); + const limitedResponse = await app.request("/v1/customers", { + method: "POST", + }); + + expect(firstResponse.status).toBe(200); + expect(limitedResponse.status).toBe(429); + expect(limitedResponse.headers.get("Retry-After")).toBeNull(); + expect(await limitedResponse.json()).toEqual({ + message: "Rate limit exceeded.", + code: "rate_limit_exceeded", + env: "live", + }); + }); }); afterAll(() => { diff --git a/server/tests/unit/redis/main-redis-routing.test.ts b/server/tests/unit/redis/main-redis-routing.test.ts new file mode 100644 index 0000000000..665f56bc03 --- /dev/null +++ b/server/tests/unit/redis/main-redis-routing.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import type { Redis } from "ioredis"; +import { + createMainRedisRouter, + selectMainRedisClient, +} from "@/external/redis/mainRedisRouting.js"; +import { MainRedisCacheConfigSchema } from "@/internal/misc/mainRedisCache/mainRedisCacheSchemas.js"; + +const asRedis = (value: object) => value as Redis; + +describe("main Redis routing", () => { + test("defaults edge config to primary", () => { + expect(MainRedisCacheConfigSchema.parse({})).toEqual({ + activeInstance: "primary", + }); + }); + + test("selects primary by default", () => { + const primary = asRedis({}); + const fallback = asRedis({}); + + expect( + selectMainRedisClient({ + activeInstance: "primary", + primary: () => primary, + fallback, + }), + ).toBe(primary); + }); + + test("selects fallback when configured", () => { + const primary = asRedis({}); + const fallback = asRedis({}); + + expect( + selectMainRedisClient({ + activeInstance: "fallback", + primary: () => primary, + fallback, + }), + ).toBe(fallback); + }); + + test("fails safely to primary when fallback is missing", () => { + const primary = asRedis({}); + + expect( + selectMainRedisClient({ + activeInstance: "fallback", + primary: () => primary, + fallback: null, + }), + ).toBe(primary); + }); + + test("routes auth and idempotency commands to the current client", async () => { + const primary = { + status: "ready", + name: "primary", + calls: [] as string[], + async get(key: string) { + this.calls.push(`get:${key}`); + return null; + }, + async set(key: string) { + this.calls.push(`set:${key}`); + return "OK"; + }, + async del(key: string) { + this.calls.push(`del:${key}`); + return 1; + }, + }; + const fallback = { + status: "ready", + name: "fallback", + calls: [] as string[], + async get(key: string) { + this.calls.push(`get:${key}`); + return null; + }, + async set(key: string) { + this.calls.push(`set:${key}`); + return "OK"; + }, + async del(key: string) { + this.calls.push(`del:${key}`); + return 1; + }, + }; + let active = primary; + const router = createMainRedisRouter({ resolve: () => asRedis(active) }); + + await router.get("secret_key:hash"); + active = fallback; + await router.set("org:live:idempotency:hash", "1"); + await router.del("org:live:idempotency:hash"); + + expect(primary.calls).toEqual(["get:secret_key:hash"]); + expect(fallback.calls).toEqual([ + "set:org:live:idempotency:hash", + "del:org:live:idempotency:hash", + ]); + }); +}); diff --git a/server/tests/unit/redis/redis-dns-lookup.test.ts b/server/tests/unit/redis/redis-dns-lookup.test.ts new file mode 100644 index 0000000000..755bed4776 --- /dev/null +++ b/server/tests/unit/redis/redis-dns-lookup.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import type { LookupOptions } from "node:dns"; +import { createRedisDnsLookup } from "@/external/redis/initUtils/redisDnsLookup.js"; + +const options = { all: false, family: 4 } satisfies LookupOptions; + +describe("Redis DNS lookup", () => { + test("uses the system lookup result when available", async () => { + const lookup = createRedisDnsLookup({ + lookupHost: (_hostname, _options, callback) => { + callback(null, "127.0.0.1", 4); + }, + resolve4Host: () => { + throw new Error("resolve4 should not run"); + }, + }); + + const result = await new Promise((resolve, reject) => { + lookup("redis.example.com", options, (error, address) => { + if (error) reject(error); + else resolve(String(address)); + }); + }); + + expect(result).toBe("127.0.0.1"); + }); + + test("falls back to resolve4 after ENOTFOUND", async () => { + const lookup = createRedisDnsLookup({ + lookupHost: (_hostname, _options, callback) => { + const error = new Error("not found") as NodeJS.ErrnoException; + error.code = "ENOTFOUND"; + callback(error, "", 4); + }, + resolve4Host: (_hostname, callback) => { + callback(null, ["192.0.2.1"]); + }, + }); + + const result = await new Promise((resolve, reject) => { + lookup("redis.example.com", options, (error, address) => { + if (error) reject(error); + else resolve(String(address)); + }); + }); + + expect(result).toBe("192.0.2.1"); + }); + + test("preserves non-DNS lookup errors", async () => { + const expected = new Error("permission denied") as NodeJS.ErrnoException; + expected.code = "EACCES"; + const lookup = createRedisDnsLookup({ + lookupHost: (_hostname, _options, callback) => { + callback(expected, "", 4); + }, + resolve4Host: () => { + throw new Error("resolve4 should not run"); + }, + }); + + const error = await new Promise((resolve) => { + lookup("redis.example.com", options, (lookupError) => { + resolve(lookupError); + }); + }); + + expect(error).toBe(expected); + }); +}); diff --git a/server/tests/unit/redis/with-redis-fail-open-gate-rejection.test.ts b/server/tests/unit/redis/with-redis-fail-open-gate-rejection.test.ts index 96503771f8..f513aafc23 100644 --- a/server/tests/unit/redis/with-redis-fail-open-gate-rejection.test.ts +++ b/server/tests/unit/redis/with-redis-fail-open-gate-rejection.test.ts @@ -1,6 +1,19 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; import { ApiVersionClass, LATEST_VERSION, RecaseError } from "@autumn/shared"; +// Capture real modules BEFORE mocking — mock.module leaks across test files +// (mock.restore does not undo it), so afterAll re-mocks with the real exports. +const realRedisV2Availability = { + ...(await import("@/external/redis/initUtils/redisV2Availability.js")), +}; +const realRunCheckV2 = { + ...(await import("@/internal/balances/check/runCheckV2.js")), +}; +const realRunTrackV3 = { + ...(await import("@/internal/balances/track/v3/runTrackV3.js")), +}; +const realQueueUtils = { ...(await import("@/queue/queueUtils.js")) }; + mock.module("@/external/redis/initUtils/redisV2Availability.js", () => ({ shouldUseRedisV2: () => true, })); @@ -79,8 +92,23 @@ const { runTrackWithRollout } = await import( ); const { ParsedCheckParamsSchema } = await import("@autumn/shared"); +const originalTrackQueueUrl = process.env.TRACK_SQS_QUEUE_URL; process.env.TRACK_SQS_QUEUE_URL = "https://sqs.test/queue"; +afterAll(() => { + process.env.TRACK_SQS_QUEUE_URL = originalTrackQueueUrl; + mock.module( + "@/external/redis/initUtils/redisV2Availability.js", + () => realRedisV2Availability, + ); + mock.module("@/internal/balances/check/runCheckV2.js", () => realRunCheckV2); + mock.module( + "@/internal/balances/track/v3/runTrackV3.js", + () => realRunTrackV3, + ); + mock.module("@/queue/queueUtils.js", () => realQueueUtils); +}); + const noopLogger = { info: () => {}, warn: () => {}, diff --git a/server/tests/unit/revenuecat/handleRevenueCatOAuthCallback.test.ts b/server/tests/unit/revenuecat/handleRevenueCatOAuthCallback.test.ts index 6b0676cd4d..e0810c7481 100644 --- a/server/tests/unit/revenuecat/handleRevenueCatOAuthCallback.test.ts +++ b/server/tests/unit/revenuecat/handleRevenueCatOAuthCallback.test.ts @@ -1,6 +1,36 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; import { OAuth2Tokens } from "arctic"; +// Real modules captured BEFORE mocking so afterAll can restore them — module +// mocks leak across test files (mock.restore does not undo them). +const MOCKED_MODULE_PATHS = [ + "@/db/initDrizzle.js", + "@/internal/platform/platformBeta/utils/oauthStateUtils.js", + "@/external/revenueCat/misc/revenuecatOAuth.js", + "@/external/revenueCat/misc/initRevenuecatCli.js", + "@/external/revenueCat/misc/RCMappingService.js", + "@/internal/orgs/OrgService.js", + "@/internal/orgs/orgUtils/clearOrgCache.js", +] as const; +const realModules = new Map>(); +for (const path of MOCKED_MODULE_PATHS) { + realModules.set(path, { ...(await import(path)) }); +} + +afterAll(() => { + for (const [path, realModule] of realModules) { + mock.module(path, () => realModule); + } +}); + type MockOAuthState = { organization_slug: string; env: string; diff --git a/server/tests/unit/shared/pooledBalanceGrantUtils.test.ts b/server/tests/unit/shared/pooledBalanceGrantUtils.test.ts new file mode 100644 index 0000000000..6f2b0d3a4a --- /dev/null +++ b/server/tests/unit/shared/pooledBalanceGrantUtils.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { + cusEntsToAllowance, + cusEntsToMaxPurchase, + cusEntToIncludedUsage, + cusEntToStartingBalance, + type FullCusEntWithFullCusProduct, + getMaxOverage, +} from "@autumn/shared"; + +const pooledCustomerEntitlement = { + id: "cus_ent_pool", + balance: 1000, + additional_balance: 0, + adjustment: 0, + entities: null, + usage_allowed: true, + replaceables: [], + rollovers: [], + customer_product: null, + entitlement: { + allowance: 0, + allowance_type: "fixed", + usage_limit: 1300, + entity_feature_id: null, + feature: { id: "messages" }, + rollover: { max: null, length: 1, duration: "month" }, + }, + pooled_balance: { granted: 1000 }, +} as unknown as FullCusEntWithFullCusProduct; + +test("pooled balance grant utilities use pooled_balance.granted", () => { + expect(cusEntToStartingBalance({ cusEnt: pooledCustomerEntitlement })).toBe( + 1000, + ); + expect(cusEntsToAllowance({ cusEnts: [pooledCustomerEntitlement] })).toBe( + 1000, + ); + expect(cusEntToIncludedUsage({ cusEnt: pooledCustomerEntitlement })).toBe( + 1000, + ); + expect(getMaxOverage({ cusEnt: pooledCustomerEntitlement })).toBe(300); + expect(cusEntsToMaxPurchase({ cusEnts: [pooledCustomerEntitlement] })).toBe( + 300, + ); +}); + +test("pooled included usage adds rollover grant when requested", () => { + const cusEntWithRollover = { + ...pooledCustomerEntitlement, + rollovers: [{ balance: 200, usage: 50 }], + } as FullCusEntWithFullCusProduct; + + expect( + cusEntToIncludedUsage({ + cusEnt: cusEntWithRollover, + withRollovers: true, + }), + ).toBe(1250); + expect(cusEntToIncludedUsage({ cusEnt: cusEntWithRollover })).toBe(1000); +}); diff --git a/server/tests/unit/webhooks/stripe-webhook-ack-mode.test.ts b/server/tests/unit/webhooks/stripe-webhook-ack-mode.test.ts new file mode 100644 index 0000000000..f4d4fa40eb --- /dev/null +++ b/server/tests/unit/webhooks/stripe-webhook-ack-mode.test.ts @@ -0,0 +1,438 @@ +/** + * TDD test for selective sync-ack of Stripe webhooks + Autumn origin detection. + * + * Contract under test: + * New types/fields: + * - classifyStripeWebhookAckMode({ event, now? }) -> "early" | "sync" + * - buildAutumnStripeIdempotencyKey / autumnStripeRequestOptions -> keys prefixed "autumn:" + * - isAutumnOriginatedStripeEvent({ event }) -> event.request.idempotency_key prefix test + * - StripeWebhookContext.webhookAckMode / webhookIdempotency hooks + * New behaviors (classifier policy): + * - checkout.session.completed / expired -> sync (always, even when Autumn-originated) + * - invoice.paid with metadata.autumn_metadata_id -> sync; otherwise early + * - invoice.finalized with metadata.vercel_installation_id -> sync; otherwise early + * - invoice.created with billing_reason subscription_cycle -> sync; otherwise early + * - customer.updated -> sync (pure external mirror, cheap handler) + * - customer.subscription.created/updated/deleted with autumn idempotency key on + * event.request -> early; with autumn-managed metadata (recent for updated/deleted, + * any age for created) -> early; otherwise sync + * - all other event types, or missing event -> early + * New behaviors (ack middleware): + * - sync mode: response waits for processing; success -> handler response + + * markCompleted(); unhandled error -> 500 + release() (so Stripe retries) + * - early mode: immediate 200 {received:true}; background success -> markCompleted(); + * background failure -> release() + * Side effects: + * - Redis two-state idempotency semantics are covered by the integration test + * tests/integration/stripe/webhookAck/stripe-webhook-idempotency-retry.test.ts + * + * Pre-impl red: classifyStripeWebhookAckMode, autumnStripeIdempotency, and + * stripeWebhookAckMiddleware do not exist yet. + * Post-impl green: classifier + renamed ack middleware implement the policy above. + */ + +import { afterAll, describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import type Stripe from "stripe"; +import { + autumnStripeRequestOptions, + buildAutumnStripeIdempotencyKey, + isAutumnOriginatedStripeEvent, +} from "@/external/stripe/common/autumnStripeIdempotency"; +import { classifyStripeWebhookAckMode } from "@/external/stripe/webhookMiddlewares/classifyStripeWebhookAckMode"; +import { stripeWebhookAckMiddleware } from "@/external/stripe/webhookMiddlewares/stripeWebhookAckMiddleware"; + +// Unit scope: the failure path must exercise idempotency hooks only, never a +// real SQS enqueue — force the replay queue off regardless of local env. +const originalReplayQueueUrl = process.env.STRIPE_WEBHOOK_SQS_QUEUE_URL; +process.env.STRIPE_WEBHOOK_SQS_QUEUE_URL = ""; +afterAll(() => { + process.env.STRIPE_WEBHOOK_SQS_QUEUE_URL = originalReplayQueueUrl; +}); + +const waitForImmediate = () => new Promise((resolve) => setImmediate(resolve)); + +const TEN_MINUTES_MS = 10 * 60 * 1000; + +const buildEvent = ({ + type, + object = {}, + request = null, +}: { + type: string; + object?: Record; + request?: { id?: string; idempotency_key?: string | null } | null; +}): Stripe.Event => + ({ + id: "evt_test", + type, + data: { object }, + request, + }) as unknown as Stripe.Event; + +// ── Contract: classifier policy table ──────────────────────────── + +describe("classifyStripeWebhookAckMode", () => { + const cases: { + name: string; + event: Stripe.Event | undefined; + expected: "early" | "sync"; + }[] = [ + { + name: "checkout.session.completed -> sync", + event: buildEvent({ type: "checkout.session.completed" }), + expected: "sync", + }, + { + name: "checkout.session.completed stays sync even when Autumn-originated", + event: buildEvent({ + type: "checkout.session.completed", + request: { idempotency_key: "autumn:attach:abc" }, + }), + expected: "sync", + }, + { + name: "checkout.session.expired -> sync", + event: buildEvent({ type: "checkout.session.expired" }), + expected: "sync", + }, + { + name: "invoice.paid with autumn_metadata_id -> sync", + event: buildEvent({ + type: "invoice.paid", + object: { metadata: { autumn_metadata_id: "meta_1" } }, + }), + expected: "sync", + }, + { + name: "invoice.paid without autumn_metadata_id -> early", + event: buildEvent({ type: "invoice.paid", object: { metadata: {} } }), + expected: "early", + }, + { + name: "invoice.finalized with vercel_installation_id -> sync", + event: buildEvent({ + type: "invoice.finalized", + object: { metadata: { vercel_installation_id: "icfg_1" } }, + }), + expected: "sync", + }, + { + name: "invoice.finalized without vercel metadata -> early", + event: buildEvent({ + type: "invoice.finalized", + object: { metadata: null }, + }), + expected: "early", + }, + { + name: "invoice.created subscription_cycle -> sync", + event: buildEvent({ + type: "invoice.created", + object: { billing_reason: "subscription_cycle" }, + }), + expected: "sync", + }, + { + name: "invoice.created manual -> early", + event: buildEvent({ + type: "invoice.created", + object: { billing_reason: "manual" }, + }), + expected: "early", + }, + { + name: "invoice.created subscription_update -> early", + event: buildEvent({ + type: "invoice.created", + object: { billing_reason: "subscription_update" }, + }), + expected: "early", + }, + { + name: "subscription.updated without markers -> sync", + event: buildEvent({ + type: "customer.subscription.updated", + object: { metadata: {} }, + }), + expected: "sync", + }, + { + name: "subscription.updated with recent autumn_managed_at -> early", + event: buildEvent({ + type: "customer.subscription.updated", + object: { metadata: { autumn_managed_at: String(Date.now()) } }, + }), + expected: "early", + }, + { + name: "subscription.updated with stale autumn_managed_at -> sync", + event: buildEvent({ + type: "customer.subscription.updated", + object: { + metadata: { + autumn_managed_at: String(Date.now() - TEN_MINUTES_MS - 1000), + }, + }, + }), + expected: "sync", + }, + { + name: "subscription.updated with autumn_managed_source -> early", + event: buildEvent({ + type: "customer.subscription.updated", + object: { metadata: { autumn_managed_source: "attach" } }, + }), + expected: "early", + }, + { + name: "subscription.updated with autumn idempotency key -> early", + event: buildEvent({ + type: "customer.subscription.updated", + object: { metadata: {} }, + request: { idempotency_key: "autumn:updateSubscription:xyz" }, + }), + expected: "early", + }, + { + name: "subscription.updated with foreign idempotency key -> sync", + event: buildEvent({ + type: "customer.subscription.updated", + object: { metadata: {} }, + request: { idempotency_key: "some-external-key" }, + }), + expected: "sync", + }, + { + name: "subscription.created with stale autumn_managed_at -> early (any age)", + event: buildEvent({ + type: "customer.subscription.created", + object: { + metadata: { + autumn_managed_at: String(Date.now() - TEN_MINUTES_MS - 1000), + }, + }, + }), + expected: "early", + }, + { + name: "subscription.created without markers -> sync", + event: buildEvent({ + type: "customer.subscription.created", + object: { metadata: {} }, + }), + expected: "sync", + }, + { + name: "subscription.deleted without markers -> sync", + event: buildEvent({ + type: "customer.subscription.deleted", + object: { metadata: {} }, + }), + expected: "sync", + }, + { + name: "subscription.deleted with autumn idempotency key -> early", + event: buildEvent({ + type: "customer.subscription.deleted", + object: { metadata: {} }, + request: { idempotency_key: "autumn:updateSubscription:abc" }, + }), + expected: "early", + }, + { + name: "customer.updated -> sync", + event: buildEvent({ type: "customer.updated" }), + expected: "sync", + }, + { + name: "customer.discount.deleted -> early (default)", + event: buildEvent({ type: "customer.discount.deleted" }), + expected: "early", + }, + { + name: "missing event -> early", + event: undefined, + expected: "early", + }, + ]; + + for (const { name, event, expected } of cases) { + test(name, () => { + expect(classifyStripeWebhookAckMode({ event })).toBe(expected); + }); + } +}); + +// ── Contract: autumn idempotency key helpers ───────────────────── + +describe("autumnStripeIdempotency", () => { + test("built keys carry the autumn prefix and are unique per call", () => { + const first = buildAutumnStripeIdempotencyKey({ source: "attach" }); + const second = buildAutumnStripeIdempotencyKey({ source: "attach" }); + + expect(first.startsWith("autumn:")).toBe(true); + expect(first).toContain("attach"); + expect(first).not.toBe(second); + }); + + test("autumnStripeRequestOptions returns a fresh idempotencyKey", () => { + const options = autumnStripeRequestOptions({ source: "invoice.create" }); + expect(options.idempotencyKey?.startsWith("autumn:")).toBe(true); + }); + + test("isAutumnOriginatedStripeEvent matches only autumn-prefixed request keys", () => { + const autumnEvent = buildEvent({ + type: "customer.subscription.updated", + request: { idempotency_key: "autumn:attach:abc" }, + }); + const foreignEvent = buildEvent({ + type: "customer.subscription.updated", + request: { idempotency_key: "their-key" }, + }); + const naturalEvent = buildEvent({ + type: "customer.subscription.updated", + request: null, + }); + + expect(isAutumnOriginatedStripeEvent({ event: autumnEvent })).toBe(true); + expect(isAutumnOriginatedStripeEvent({ event: foreignEvent })).toBe(false); + expect(isAutumnOriginatedStripeEvent({ event: naturalEvent })).toBe(false); + }); +}); + +// ── Contract: ack middleware behavior per mode ─────────────────── + +type HookCalls = { completed: number; released: number }; + +const createApp = ({ + event, + handler, +}: { + event: Stripe.Event; + handler: (c: { + json: (body: unknown, status: number) => Response; + }) => Response | Promise; +}) => { + const app = new Hono(); + const hookCalls: HookCalls = { completed: 0, released: 0 }; + + app.use("*", async (c, next) => { + (c as never as { set: (key: string, value: unknown) => void }).set("ctx", { + logger: { error: () => {}, warn: () => {}, info: () => {} }, + stripeEvent: event, + webhookIdempotency: { + markCompleted: async () => { + hookCalls.completed++; + }, + release: async () => { + hookCalls.released++; + }, + }, + }); + await next(); + }); + + app.post("/webhook", stripeWebhookAckMiddleware as never, handler as never); + + return { app, hookCalls }; +}; + +const syncEvent = buildEvent({ + type: "customer.subscription.updated", + object: { metadata: {} }, +}); + +const earlyEvent = buildEvent({ + type: "invoice.created", + object: { billing_reason: "manual" }, +}); + +describe("stripeWebhookAckMiddleware sync mode", () => { + test("waits for processing and returns the handler response, then marks completed", async () => { + let processed = false; + const { app, hookCalls } = createApp({ + event: syncEvent, + handler: (c) => { + processed = true; + return c.json({ success: true }, 200); + }, + }); + + const response = await app.request("/webhook", { method: "POST" }); + + expect(processed).toBe(true); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true }); + expect(hookCalls.completed).toBe(1); + expect(hookCalls.released).toBe(0); + }); + + test("returns 500 and releases the idempotency lock when processing throws", async () => { + const { app, hookCalls } = createApp({ + event: syncEvent, + handler: () => { + throw new Error("handler exploded"); + }, + }); + + const response = await app.request("/webhook", { method: "POST" }); + + expect(response.status).toBe(500); + expect(hookCalls.completed).toBe(0); + expect(hookCalls.released).toBe(1); + }); +}); + +describe("stripeWebhookAckMiddleware early mode", () => { + test("acks immediately, then marks completed after background success", async () => { + let resolveProcessing!: () => void; + const processing = new Promise((resolve) => { + resolveProcessing = resolve; + }); + let processed = false; + + const { app, hookCalls } = createApp({ + event: earlyEvent, + handler: async (c) => { + await processing; + processed = true; + return c.json({ success: true }, 200); + }, + }); + + const response = await app.request("/webhook", { method: "POST" }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ received: true }); + expect(processed).toBe(false); + + resolveProcessing(); + await waitForImmediate(); + await waitForImmediate(); + + expect(processed).toBe(true); + expect(hookCalls.completed).toBe(1); + expect(hookCalls.released).toBe(0); + }); + + test("still acks 200 but releases the lock when background processing fails", async () => { + const { app, hookCalls } = createApp({ + event: earlyEvent, + handler: () => { + throw new Error("background failure"); + }, + }); + + const response = await app.request("/webhook", { method: "POST" }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ received: true }); + + await waitForImmediate(); + await waitForImmediate(); + + expect(hookCalls.completed).toBe(0); + expect(hookCalls.released).toBe(1); + }); +}); diff --git a/server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts b/server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts index 1ffaf7c460..06658ed1cc 100644 --- a/server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts +++ b/server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { Hono } from "hono"; -import { stripeWebhookEarlyAckMiddleware } from "@/external/stripe/webhookMiddlewares/stripeWebhookEarlyAckMiddleware"; +import { stripeWebhookAckMiddleware } from "@/external/stripe/webhookMiddlewares/stripeWebhookAckMiddleware"; const waitForImmediate = () => new Promise((resolve) => setImmediate(resolve)); @@ -19,11 +19,11 @@ const createApp = () => { return app; }; -describe("stripeWebhookEarlyAckMiddleware", () => { +describe("stripeWebhookAckMiddleware", () => { test("uses executionCtx.waitUntil when the runtime provides it", async () => { const waits: Promise[] = []; let processed = false; - const response = await stripeWebhookEarlyAckMiddleware( + const response = await stripeWebhookAckMiddleware( { get: () => ({ logger: { error: () => {} }, @@ -39,6 +39,7 @@ describe("stripeWebhookEarlyAckMiddleware", () => { }, ); + if (!response) throw new Error("expected an early-ack response"); expect(response.status).toBe(200); expect(await response.json()).toEqual({ received: true }); expect(waits).toHaveLength(1); @@ -50,10 +51,12 @@ describe("stripeWebhookEarlyAckMiddleware", () => { test("does not run downstream twice when waitUntil throws", async () => { const errors: unknown[] = []; let runs = 0; - const response = await stripeWebhookEarlyAckMiddleware( + const response = await stripeWebhookAckMiddleware( { get: () => ({ - logger: { error: (_message: string, meta: unknown) => errors.push(meta) }, + logger: { + error: (_message: string, meta: unknown) => errors.push(meta), + }, }), json: (body: unknown, status: number) => new Response(JSON.stringify(body), { status }), @@ -68,6 +71,7 @@ describe("stripeWebhookEarlyAckMiddleware", () => { }, ); + if (!response) throw new Error("expected an early-ack response"); expect(response.status).toBe(200); await Promise.resolve(); await waitForImmediate(); @@ -83,15 +87,11 @@ describe("stripeWebhookEarlyAckMiddleware", () => { resolveProcessing = resolve; }); - app.post( - "/webhook", - stripeWebhookEarlyAckMiddleware as never, - async (c) => { - await processing; - processed = true; - return c.json({ processed: true }, 200); - }, - ); + app.post("/webhook", stripeWebhookAckMiddleware as never, async (c) => { + await processing; + processed = true; + return c.json({ processed: true }, 200); + }); const response = await app.request("/webhook", { method: "POST" }); @@ -108,7 +108,7 @@ describe("stripeWebhookEarlyAckMiddleware", () => { const app = createApp(); let started = false; - app.post("/webhook", stripeWebhookEarlyAckMiddleware as never, (c) => { + app.post("/webhook", stripeWebhookAckMiddleware as never, (c) => { started = true; return c.json({ processed: true }, 200); }); diff --git a/server/tests/utils/browserPool/playwright/invoiceCheckout.ts b/server/tests/utils/browserPool/playwright/invoiceCheckout.ts index d0a60ab82f..46ffe0bf84 100644 --- a/server/tests/utils/browserPool/playwright/invoiceCheckout.ts +++ b/server/tests/utils/browserPool/playwright/invoiceCheckout.ts @@ -31,8 +31,15 @@ export const invoiceCheckout = async ({ // Try to find the iframe via #payment-element first (embedded Payment Element) // Use a specific selector to avoid matching the bank search results iframe try { - await page.waitForSelector('#payment-element iframe[title="Secure payment input frame"]', { timeout: 10000 }); - paymentFrame = page.frameLocator('#payment-element iframe[title="Secure payment input frame"]'); + await page.waitForSelector( + '#payment-element iframe[title="Secure payment input frame"]', + { timeout: 10000 }, + ); + paymentFrame = page + .frameLocator( + '#payment-element iframe[title="Secure payment input frame"]', + ) + .first(); console.log("[invoiceCheckout] Found #payment-element iframe"); } catch { // Fallback: try first iframe in #payment-element @@ -105,15 +112,24 @@ export const invoiceCheckout = async ({ // Now try to find the payment frame again try { - await page.waitForSelector('#payment-element iframe[title="Secure payment input frame"]', { timeout: 10000 }); - paymentFrame = page.frameLocator('#payment-element iframe[title="Secure payment input frame"]'); + await page.waitForSelector( + '#payment-element iframe[title="Secure payment input frame"]', + { timeout: 10000 }, + ); + paymentFrame = page + .frameLocator( + '#payment-element iframe[title="Secure payment input frame"]', + ) + .first(); console.log( "[invoiceCheckout] Found #payment-element iframe after card click", ); } catch { // Fallback: first iframe try { - await page.waitForSelector("#payment-element iframe", { timeout: 5000 }); + await page.waitForSelector("#payment-element iframe", { + timeout: 5000, + }); paymentFrame = page.frameLocator("#payment-element iframe").first(); console.log( "[invoiceCheckout] Found #payment-element iframe after card click (first)", diff --git a/server/tests/utils/cusProductUtils/resetTestUtils.ts b/server/tests/utils/cusProductUtils/resetTestUtils.ts index 8e250cbce3..28a729a7cf 100644 --- a/server/tests/utils/cusProductUtils/resetTestUtils.ts +++ b/server/tests/utils/cusProductUtils/resetTestUtils.ts @@ -1,7 +1,8 @@ import { + CusProductStatus, + cusProductsToCusEnts, customerEntitlements, type FullCustomer, - fullCustomerToCustomerEntitlements, } from "@autumn/shared"; import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js"; import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; @@ -213,9 +214,12 @@ export const expireAllCusEntsForReset = async ({ idOrInternalId: customerId, }); - const cusEnts = fullCustomerToCustomerEntitlements({ - fullCustomer, - featureId, + // cusProductsToCusEnts skips the entity scoping filter — "ALL" here + // includes entity-scoped products' entitlements. + const cusEnts = cusProductsToCusEnts({ + cusProducts: fullCustomer.customer_products, + featureIds: [featureId], + inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], }); if (cusEnts.length === 0) { diff --git a/shared/api/models.ts b/shared/api/models.ts index 9318290ebc..7b17b3b742 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -98,6 +98,8 @@ export * from "./features/prevVersions/featureV0OpModels.js"; export * from "./others/apiInvoice/apiInvoiceItem.js"; export * from "./others/apiInvoice/apiInvoiceV1.js"; export * from "./others/apiInvoice/apiListInvoiceV1.js"; +export * from "./others/apiInvoice/insertInvoicesParams.js"; +export * from "./others/apiInvoice/insertInvoicesResponse.js"; export * from "./others/apiInvoice/listInvoicesParams.js"; // Models export * from "./platform/platformModels.js"; diff --git a/shared/api/others/apiInvoice/insertInvoicesParams.ts b/shared/api/others/apiInvoice/insertInvoicesParams.ts new file mode 100644 index 0000000000..e32e3352cc --- /dev/null +++ b/shared/api/others/apiInvoice/insertInvoicesParams.ts @@ -0,0 +1,53 @@ +import { z } from "zod/v4"; +import { InvoiceStatus } from "../../../models/cusModels/invoiceModels/invoiceModels.js"; +import { ProcessorType } from "../../../models/genModels/genEnums.js"; + +export const InsertInvoiceParamsSchema = z.object({ + customer_id: z.string().meta({ + description: "The customer this invoice belongs to.", + }), + entity_id: z.string().optional().meta({ + description: "The entity this invoice belongs to.", + internal: true, + }), + plan_ids: z.array(z.string()).default([]).meta({ + description: "Plan IDs represented by this invoice.", + }), + stripe_id: z.string().meta({ + description: "The processor's stable invoice ID.", + }), + processor_type: z.enum(ProcessorType).default(ProcessorType.Stripe).meta({ + description: "The billing processor that owns this invoice.", + }), + status: z.enum(InvoiceStatus).meta({ + description: "The invoice status.", + }), + total: z.number().meta({ + description: "The invoice total in major currency units.", + }), + amount_paid: z.number().nullable().default(null).meta({ + description: "The amount paid in major currency units.", + }), + refunded_amount: z.number().default(0).meta({ + description: "The refunded amount in major currency units.", + }), + currency: z.string().optional().meta({ + description: + "The currency code. Defaults to the organization's default currency.", + }), + created_at: z.number().meta({ + description: "The invoice creation timestamp in milliseconds.", + }), + hosted_invoice_url: z.string().nullable().default(null).meta({ + description: "The hosted invoice URL, when available.", + }), +}); + +export const InsertInvoicesParamsSchema = z.object({ + invoices: z.array(InsertInvoiceParamsSchema).max(500).meta({ + description: "Invoices to insert or update, in response order.", + }), +}); + +export type InsertInvoiceParams = z.infer; +export type InsertInvoicesParams = z.infer; diff --git a/shared/api/others/apiInvoice/insertInvoicesResponse.ts b/shared/api/others/apiInvoice/insertInvoicesResponse.ts new file mode 100644 index 0000000000..2f7bc3b440 --- /dev/null +++ b/shared/api/others/apiInvoice/insertInvoicesResponse.ts @@ -0,0 +1,26 @@ +import { z } from "zod/v4"; +import { ApiInvoiceV1Schema } from "./apiInvoiceV1.js"; + +export const ApiInsertedInvoiceSchema = ApiInvoiceV1Schema.extend({ + id: z.string().meta({ description: "The Autumn invoice ID." }), + customer_id: z.string().meta({ + description: "The customer this invoice belongs to.", + }), + amount_paid: z.number().nullable().meta({ + description: "The amount paid in major currency units.", + }), + refunded_amount: z.number().meta({ + description: "The refunded amount in major currency units.", + }), +}); + +export const InsertInvoicesResponseSchema = z.object({ + invoices: z.array(ApiInsertedInvoiceSchema).meta({ + description: "Inserted or updated invoices in request order.", + }), +}); + +export type ApiInsertedInvoice = z.infer; +export type InsertInvoicesResponse = z.infer< + typeof InsertInvoicesResponseSchema +>; diff --git a/shared/drizzle/0047_dry_the_enforcers.sql b/shared/drizzle/0047_dry_the_enforcers.sql new file mode 100644 index 0000000000..8239cfe900 --- /dev/null +++ b/shared/drizzle/0047_dry_the_enforcers.sql @@ -0,0 +1,2 @@ +ALTER TABLE "customer_entitlements" ADD COLUMN "reset_by_invoice" boolean;--> statement-breakpoint +CREATE INDEX CONCURRENTLY "idx_customer_entitlements_reset_scan" ON "customer_entitlements" USING btree ("next_reset_at","id" COLLATE "C") WHERE "customer_entitlements"."expired" IS NOT TRUE AND "customer_entitlements"."reset_by_invoice" IS NOT TRUE AND "customer_entitlements"."next_reset_at" IS NOT NULL; \ No newline at end of file diff --git a/shared/drizzle/0048_clever_professor_monster.sql b/shared/drizzle/0048_clever_professor_monster.sql deleted file mode 100644 index c90c1c5426..0000000000 --- a/shared/drizzle/0048_clever_professor_monster.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "customer_entitlements" ADD COLUMN "pooled_balance_id" text;--> statement-breakpoint -ALTER TABLE "customer_entitlements" ADD COLUMN "pooled_contribution_id" text;--> statement-breakpoint diff --git a/shared/drizzle/0047_next_avengers.sql b/shared/drizzle/0048_lucky_loa.sql similarity index 92% rename from shared/drizzle/0047_next_avengers.sql rename to shared/drizzle/0048_lucky_loa.sql index 625c60fbd7..94d2b29db7 100644 --- a/shared/drizzle/0047_next_avengers.sql +++ b/shared/drizzle/0048_lucky_loa.sql @@ -51,6 +51,8 @@ CREATE TABLE "pooled_balances" ( ); --> statement-breakpoint ALTER TABLE "customer_entitlements" ADD COLUMN "is_pooled_balance" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "customer_entitlements" ADD COLUMN "pooled_balance_id" text;--> statement-breakpoint +ALTER TABLE "customer_entitlements" ADD COLUMN "pooled_contribution_id" text;--> statement-breakpoint ALTER TABLE "entitlements" ADD COLUMN "pooled" boolean DEFAULT false NOT NULL;--> statement-breakpoint ALTER TABLE "pooled_balance_contributions" ADD CONSTRAINT "pooled_balance_contributions_pool_fkey" FOREIGN KEY ("pooled_balance_id") REFERENCES "public"."pooled_balances"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "pooled_balance_contributions" ADD CONSTRAINT "pooled_balance_contributions_customer_product_fkey" FOREIGN KEY ("source_customer_product_id") REFERENCES "public"."customer_products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint @@ -64,4 +66,5 @@ CREATE INDEX CONCURRENTLY "idx_pooled_balances_org" ON "pooled_balances" USING b CREATE INDEX CONCURRENTLY "idx_pooled_balances_reset_mode" ON "pooled_balances" USING btree ("internal_customer_id","reset_mode");--> statement-breakpoint CREATE INDEX CONCURRENTLY "idx_pooled_balances_feature" ON "pooled_balances" USING btree ("internal_feature_id");--> statement-breakpoint CREATE INDEX CONCURRENTLY "idx_pooled_balances_stripe_subscription" ON "pooled_balances" USING btree ("internal_customer_id","stripe_subscription_id") WHERE "pooled_balances"."stripe_subscription_id" IS NOT NULL;--> statement-breakpoint -CREATE INDEX CONCURRENTLY "idx_pooled_balances_customer_license" ON "pooled_balances" USING btree ("internal_customer_id","customer_license_link_id") WHERE "pooled_balances"."customer_license_link_id" IS NOT NULL; +CREATE INDEX CONCURRENTLY "idx_pooled_balances_customer_license" ON "pooled_balances" USING btree ("internal_customer_id","customer_license_link_id") WHERE "pooled_balances"."customer_license_link_id" IS NOT NULL;--> statement-breakpoint +CREATE INDEX CONCURRENTLY "idx_customer_entitlements_pooled_contribution" ON "customer_entitlements" USING btree ("pooled_contribution_id") WHERE "customer_entitlements"."pooled_contribution_id" IS NOT NULL; \ No newline at end of file diff --git a/shared/drizzle/meta/0047_snapshot.json b/shared/drizzle/meta/0047_snapshot.json index b48603926b..78d95d462d 100644 --- a/shared/drizzle/meta/0047_snapshot.json +++ b/shared/drizzle/meta/0047_snapshot.json @@ -1,5 +1,5 @@ { - "id": "0f8ff78e-c90d-4e74-81fb-8e0866c3c2aa", + "id": "7d0fd194-fc6f-4c5e-9c2e-949b1dd95959", "prevId": "c3041e96-b8d2-4e62-b2b1-430e4d6c8989", "version": "7", "dialect": "postgresql", @@ -1576,13 +1576,6 @@ "notNull": true, "default": false }, - "is_pooled_balance": { - "name": "is_pooled_balance", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, "adjustment": { "name": "adjustment", "type": "numeric", @@ -1638,6 +1631,12 @@ "type": "boolean", "primaryKey": false, "notNull": false + }, + "reset_by_invoice": { + "name": "reset_by_invoice", + "type": "boolean", + "primaryKey": false, + "notNull": false } }, "indexes": { @@ -1867,6 +1866,28 @@ "concurrently": true, "method": "btree", "with": {} + }, + "idx_customer_entitlements_reset_scan": { + "name": "idx_customer_entitlements_reset_scan", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"expired\" IS NOT TRUE AND \"customer_entitlements\".\"reset_by_invoice\" IS NOT TRUE AND \"customer_entitlements\".\"next_reset_at\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -2366,7 +2387,7 @@ "columnsTo": [ "id" ], - "onDelete": "cascade", + "onDelete": "no action", "onUpdate": "no action" } }, @@ -2935,7 +2956,7 @@ "columnsTo": [ "id" ], - "onDelete": "cascade", + "onDelete": "no action", "onUpdate": "no action" }, "customer_products_internal_customer_id_fkey": { @@ -3818,13 +3839,6 @@ "notNull": false, "default": "null" }, - "pooled": { - "name": "pooled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, "org_id": { "name": "org_id", "type": "text", @@ -7525,461 +7539,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.pooled_balance_contributions": { - "name": "pooled_balance_contributions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "pooled_balance_id": { - "name": "pooled_balance_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_customer_product_id": { - "name": "source_customer_product_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_customer_entitlement_id": { - "name": "source_customer_entitlement_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "current_contribution": { - "name": "current_contribution", - "type": "numeric", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "next_cycle_contribution": { - "name": "next_cycle_contribution", - "type": "numeric", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "effective_at": { - "name": "effective_at", - "type": "numeric", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "numeric", - "primaryKey": false, - "notNull": true, - "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" - }, - "updated_at": { - "name": "updated_at", - "type": "numeric", - "primaryKey": false, - "notNull": true, - "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" - } - }, - "indexes": { - "idx_pooled_balance_contributions_pool": { - "name": "idx_pooled_balance_contributions_pool", - "columns": [ - { - "expression": "pooled_balance_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": true, - "method": "btree", - "with": {} - }, - "idx_pooled_balance_contributions_source_customer_entitlement": { - "name": "idx_pooled_balance_contributions_source_customer_entitlement", - "columns": [ - { - "expression": "source_customer_entitlement_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": true, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "pooled_balance_contributions_pool_fkey": { - "name": "pooled_balance_contributions_pool_fkey", - "tableFrom": "pooled_balance_contributions", - "tableTo": "pooled_balances", - "columnsFrom": [ - "pooled_balance_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "pooled_balance_contributions_customer_product_fkey": { - "name": "pooled_balance_contributions_customer_product_fkey", - "tableFrom": "pooled_balance_contributions", - "tableTo": "customer_products", - "columnsFrom": [ - "source_customer_product_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "pooled_balance_contributions_customer_entitlement_fkey": { - "name": "pooled_balance_contributions_customer_entitlement_fkey", - "tableFrom": "pooled_balance_contributions", - "tableTo": "customer_entitlements", - "columnsFrom": [ - "source_customer_entitlement_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "unique_pooled_balance_contribution": { - "name": "unique_pooled_balance_contribution", - "nullsNotDistinct": false, - "columns": [ - "source_customer_entitlement_id" - ] - } - }, - "policies": {}, - "checkConstraints": { - "pooled_balance_contributions_current_non_negative": { - "name": "pooled_balance_contributions_current_non_negative", - "value": "\"pooled_balance_contributions\".\"current_contribution\" >= 0" - }, - "pooled_balance_contributions_next_non_negative": { - "name": "pooled_balance_contributions_next_non_negative", - "value": "\"pooled_balance_contributions\".\"next_cycle_contribution\" >= 0" - } - }, - "isRLSEnabled": false - }, - "public.pooled_balances": { - "name": "pooled_balances", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "org_id": { - "name": "org_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "env": { - "name": "env", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "internal_customer_id": { - "name": "internal_customer_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "internal_feature_id": { - "name": "internal_feature_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "granted": { - "name": "granted", - "type": "numeric", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "interval": { - "name": "interval", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "interval_count": { - "name": "interval_count", - "type": "numeric", - "primaryKey": false, - "notNull": true, - "default": 1 - }, - "reset_cycle_anchor": { - "name": "reset_cycle_anchor", - "type": "numeric", - "primaryKey": false, - "notNull": false - }, - "reset_mode": { - "name": "reset_mode", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "stripe_subscription_id": { - "name": "stripe_subscription_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "customer_license_link_id": { - "name": "customer_license_link_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "rollover_signature": { - "name": "rollover_signature", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'none'" - }, - "customer_entitlement_id": { - "name": "customer_entitlement_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "last_applied_reset_at": { - "name": "last_applied_reset_at", - "type": "numeric", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "numeric", - "primaryKey": false, - "notNull": true, - "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" - }, - "updated_at": { - "name": "updated_at", - "type": "numeric", - "primaryKey": false, - "notNull": true, - "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" - } - }, - "indexes": { - "idx_pooled_balances_org": { - "name": "idx_pooled_balances_org", - "columns": [ - { - "expression": "org_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": true, - "method": "btree", - "with": {} - }, - "idx_pooled_balances_reset_mode": { - "name": "idx_pooled_balances_reset_mode", - "columns": [ - { - "expression": "internal_customer_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "reset_mode", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": true, - "method": "btree", - "with": {} - }, - "idx_pooled_balances_feature": { - "name": "idx_pooled_balances_feature", - "columns": [ - { - "expression": "internal_feature_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": true, - "method": "btree", - "with": {} - }, - "idx_pooled_balances_stripe_subscription": { - "name": "idx_pooled_balances_stripe_subscription", - "columns": [ - { - "expression": "internal_customer_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "stripe_subscription_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"pooled_balances\".\"stripe_subscription_id\" IS NOT NULL", - "concurrently": true, - "method": "btree", - "with": {} - }, - "idx_pooled_balances_customer_license": { - "name": "idx_pooled_balances_customer_license", - "columns": [ - { - "expression": "internal_customer_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "customer_license_link_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"pooled_balances\".\"customer_license_link_id\" IS NOT NULL", - "concurrently": true, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "pooled_balances_customer_fkey": { - "name": "pooled_balances_customer_fkey", - "tableFrom": "pooled_balances", - "tableTo": "customers", - "columnsFrom": [ - "internal_customer_id" - ], - "columnsTo": [ - "internal_id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "pooled_balances_feature_fkey": { - "name": "pooled_balances_feature_fkey", - "tableFrom": "pooled_balances", - "tableTo": "features", - "columnsFrom": [ - "internal_feature_id" - ], - "columnsTo": [ - "internal_id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "pooled_balances_customer_entitlement_fkey": { - "name": "pooled_balances_customer_entitlement_fkey", - "tableFrom": "pooled_balances", - "tableTo": "customer_entitlements", - "columnsFrom": [ - "customer_entitlement_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "unique_pooled_balance": { - "name": "unique_pooled_balance", - "nullsNotDistinct": true, - "columns": [ - "internal_customer_id", - "internal_feature_id", - "interval", - "interval_count", - "reset_cycle_anchor", - "reset_mode", - "stripe_subscription_id", - "customer_license_link_id", - "rollover_signature" - ] - }, - "unique_pooled_balance_customer_entitlement": { - "name": "unique_pooled_balance_customer_entitlement", - "nullsNotDistinct": false, - "columns": [ - "customer_entitlement_id" - ] - } - }, - "policies": {}, - "checkConstraints": { - "pooled_balances_interval_count_positive": { - "name": "pooled_balances_interval_count_positive", - "value": "\"pooled_balances\".\"interval_count\" > 0" - }, - "pooled_balances_granted_non_negative": { - "name": "pooled_balances_granted_non_negative", - "value": "\"pooled_balances\".\"granted\" >= 0" - }, - "pooled_balances_reset_mode_valid": { - "name": "pooled_balances_reset_mode_valid", - "value": "\"pooled_balances\".\"reset_mode\" IN ('lazy', 'subscription', 'lifetime')" - }, - "pooled_balances_lifecycle_ids_valid": { - "name": "pooled_balances_lifecycle_ids_valid", - "value": "(\n\t\t\t\t\"pooled_balances\".\"reset_mode\" = 'subscription'\n\t\t\t\tAND \"pooled_balances\".\"stripe_subscription_id\" IS NOT NULL\n\t\t\t\tAND \"pooled_balances\".\"customer_license_link_id\" IS NULL\n\t\t\t) OR (\n\t\t\t\t\"pooled_balances\".\"reset_mode\" = 'lazy'\n\t\t\t\tAND \"pooled_balances\".\"stripe_subscription_id\" IS NULL\n\t\t\t) OR (\n\t\t\t\t\"pooled_balances\".\"reset_mode\" = 'lifetime'\n\t\t\t\tAND \"pooled_balances\".\"stripe_subscription_id\" IS NULL\n\t\t\t\tAND \"pooled_balances\".\"customer_license_link_id\" IS NULL\n\t\t\t)" - } - }, - "isRLSEnabled": false - }, "public.prices": { "name": "prices", "schema": "", @@ -10327,4 +9886,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/shared/drizzle/meta/0048_snapshot.json b/shared/drizzle/meta/0048_snapshot.json index 761a0cc5eb..00cd2935a4 100644 --- a/shared/drizzle/meta/0048_snapshot.json +++ b/shared/drizzle/meta/0048_snapshot.json @@ -1,6 +1,6 @@ { - "id": "423789c0-a852-42b3-8094-1bfc4d1413ae", - "prevId": "0f8ff78e-c90d-4e74-81fb-8e0866c3c2aa", + "id": "26cd117b-ba1c-4603-8ec2-ce16ae964ec7", + "prevId": "7d0fd194-fc6f-4c5e-9c2e-949b1dd95959", "version": "7", "dialect": "postgresql", "tables": { @@ -1650,6 +1650,12 @@ "type": "boolean", "primaryKey": false, "notNull": false + }, + "reset_by_invoice": { + "name": "reset_by_invoice", + "type": "boolean", + "primaryKey": false, + "notNull": false } }, "indexes": { @@ -1879,6 +1885,44 @@ "concurrently": true, "method": "btree", "with": {} + }, + "idx_customer_entitlements_pooled_contribution": { + "name": "idx_customer_entitlements_pooled_contribution", + "columns": [ + { + "expression": "pooled_contribution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"pooled_contribution_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_reset_scan": { + "name": "idx_customer_entitlements_reset_scan", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"expired\" IS NOT TRUE AND \"customer_entitlements\".\"reset_by_invoice\" IS NOT TRUE AND \"customer_entitlements\".\"next_reset_at\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} } }, "foreignKeys": { diff --git a/shared/drizzle/meta/_journal.json b/shared/drizzle/meta/_journal.json index a7b099f504..a617c28a8c 100644 --- a/shared/drizzle/meta/_journal.json +++ b/shared/drizzle/meta/_journal.json @@ -334,15 +334,15 @@ { "idx": 47, "version": "7", - "when": 1784665621208, - "tag": "0047_next_avengers", + "when": 1784903183283, + "tag": "0047_dry_the_enforcers", "breakpoints": true }, { "idx": 48, "version": "7", - "when": 1784672842480, - "tag": "0048_clever_professor_monster", + "when": 1784909275551, + "tag": "0048_lucky_loa", "breakpoints": true } ] diff --git a/shared/models/billingModels/context/customerLicenseBillingContext.ts b/shared/models/billingModels/context/customerLicenseBillingContext.ts index 342a0ba639..64ff1677ac 100644 --- a/shared/models/billingModels/context/customerLicenseBillingContext.ts +++ b/shared/models/billingModels/context/customerLicenseBillingContext.ts @@ -1,11 +1,11 @@ import type { LicenseBillingPriceRow } from "../licenseBillingPriceRow.js"; -/** License billing state loaded once at setup (the facts that cost a DB - * read); computes stay pure. Grows a sibling field per future fact. */ +/** License billing state loaded once; JSON-safe because deferred plans persist + * this context to metadata JSONB. */ export type CustomerLicenseBillingContext = { // Assigned seats' snapshot charges as (price × count), the free // `included` seats already excluded. licenseBillingPriceRows: LicenseBillingPriceRow[]; - assignedSeatCountByCustomerLicenseId: Map; - projectedPlanLicenseIds: Set; + assignedSeatCountByCustomerLicenseId: Record; + projectedPlanLicenseIds: string[]; }; diff --git a/shared/models/billingModels/plan/pooledBalancePlan.ts b/shared/models/billingModels/plan/pooledBalancePlan.ts index e2b9c42c0e..a193a51b13 100644 --- a/shared/models/billingModels/plan/pooledBalancePlan.ts +++ b/shared/models/billingModels/plan/pooledBalancePlan.ts @@ -5,12 +5,19 @@ import type { InsertPooledBalanceContribution, } from "../../pooledBalanceModels/pooledBalanceTable.js"; +export const PooledBalanceUpdateSchema = z.object({ + pooledCustomerEntitlement: z.custom(), + balanceDelta: z.number(), + grantedDelta: z.number(), +}); + export const PooledBalancePlanSchema = z.object({ insertPoolBalances: z.array(z.custom()), - updatePoolBalances: z.array(z.custom()), + updatePoolBalances: z.array(PooledBalanceUpdateSchema), insertPoolContributions: z.array(z.custom()), updatePoolContributions: z.array(z.custom()), deletePoolContributions: z.array(z.custom()), }); +export type PooledBalanceUpdate = z.infer; export type PooledBalancePlan = z.infer; diff --git a/shared/models/cusModels/billingControls/customerBillingControls.test.ts b/shared/models/cusModels/billingControls/customerBillingControls.test.ts new file mode 100644 index 0000000000..28378e1fa4 --- /dev/null +++ b/shared/models/cusModels/billingControls/customerBillingControls.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; +import { AutoTopupSchema } from "./customerBillingControls"; + +describe("AutoTopupSchema", () => { + test("allows a negative balance threshold", () => { + expect( + AutoTopupSchema.parse({ + feature_id: "credits", + enabled: true, + threshold: -350_000, + quantity: 350_000, + }), + ).toMatchObject({ threshold: -350_000 }); + }); +}); diff --git a/shared/models/cusModels/billingControls/customerBillingControls.ts b/shared/models/cusModels/billingControls/customerBillingControls.ts index 4ff8ce18a5..1b8a307b83 100644 --- a/shared/models/cusModels/billingControls/customerBillingControls.ts +++ b/shared/models/cusModels/billingControls/customerBillingControls.ts @@ -33,58 +33,6 @@ export const BILLING_CONTROL_KEYS = [ export type BillingControlKey = (typeof BILLING_CONTROL_KEYS)[number]; -export const pickBillingControlColumns = ( - source: Partial | null | undefined, -): Partial => { - if (!source) return {}; - - return Object.fromEntries( - BILLING_CONTROL_KEYS.flatMap((key) => - source[key] === undefined ? [] : [[key, source[key]]], - ), - ) as Partial; -}; - -export const billingControlsFromColumns = ( - source: Partial | null | undefined, -): CustomerBillingControls => { - if (!source) return {}; - - return Object.fromEntries( - BILLING_CONTROL_KEYS.flatMap((key) => - source[key] == null ? [] : [[key, source[key]]], - ), - ) as CustomerBillingControls; -}; - -/** Canonical form for change detection: skip_overage_billing false ≡ unset. */ -export const normalizeBillingControlsForCompare = ( - billingControls: CustomerBillingControls | null | undefined, -): CustomerBillingControls | undefined => { - if (!billingControls?.spend_limits) return billingControls ?? undefined; - - return { - ...billingControls, - spend_limits: billingControls.spend_limits.map((spendLimit) => { - if (spendLimit.skip_overage_billing === true) return spendLimit; - const { skip_overage_billing: _dropped, ...rest } = spendLimit; - return rest; - }), - }; -}; - -export const mergeBillingControls = ( - current: CustomerBillingControls | null | undefined, - patch: CustomerBillingControls | null | undefined, -): CustomerBillingControls | undefined => { - if (!patch) return current ?? undefined; - - return billingControlsFromColumns({ - ...(current ?? {}), - ...pickBillingControlColumns(patch), - }); -}; - export const AutoTopupPurchaseLimitSchema = z.object({ interval: PurchaseLimitIntervalEnum.meta({ description: "The time interval for the purchase limit window.", @@ -97,6 +45,19 @@ export const AutoTopupPurchaseLimitSchema = z.object({ }), }); +/** + * Customer create/update params only. `count` is runtime state written to + * `auto_topup_limit_states` and must never be persisted on `customers.auto_topups` + * / `products.auto_topups` JSONB — strip via `stripAutoTopupCountsForStorage`. + */ +export const AutoTopupPurchaseLimitParamsSchema = + AutoTopupPurchaseLimitSchema.extend({ + count: z.number().min(0).optional().meta({ + description: + "Set the current window's consumed auto top-up count. Omit to leave runtime state unchanged.", + }), + }); + export const AutoTopupSchema = z.object({ feature_id: z.string().meta({ description: "The ID of the feature (credit balance) to auto top-up.", @@ -104,7 +65,7 @@ export const AutoTopupSchema = z.object({ enabled: z.boolean().default(false).meta({ description: "Whether auto top-up is enabled.", }), - threshold: z.number().min(0).meta({ + threshold: z.number().meta({ description: "When the balance drops below this threshold, an auto top-up will be purchased.", }), @@ -120,6 +81,13 @@ export const AutoTopupSchema = z.object({ }), }); +export const AutoTopupParamsSchema = AutoTopupSchema.extend({ + purchase_limit: AutoTopupPurchaseLimitParamsSchema.optional().meta({ + description: + "Optional rate limit to cap how often auto top-ups occur. Pass count to set the current window's consumed top-ups.", + }), +}); + /** * Expanded purchase_limit shape that augments the static config with runtime * tracking state from `auto_topup_limit_states`. Only emitted on responses @@ -199,9 +167,126 @@ export const DbBillingControlsSchema = z.object({ overage_allowed: z.array(DbOverageAllowedSchema).nullish(), }); +export type AutoTopupPurchaseLimit = z.infer< + typeof AutoTopupPurchaseLimitSchema +>; +export type AutoTopupPurchaseLimitParams = z.input< + typeof AutoTopupPurchaseLimitParamsSchema +>; +export type ExpandedPurchaseLimit = z.infer; +export type AutoTopup = z.infer; +export type AutoTopupParams = z.input; +export type AutoTopupResponse = z.infer; +export type CustomerBillingControls = z.infer< + typeof CustomerBillingControlsSchema +>; +export type DbBillingControls = z.infer; + +/** Strip runtime `count` before persisting auto_topups JSONB. */ +export const stripAutoTopupCountsForStorage = ( + autoTopups: AutoTopupParams[] | AutoTopup[] | null | undefined, +): AutoTopup[] | null | undefined => { + if (autoTopups == null) return autoTopups; + + return autoTopups.map((topup) => + AutoTopupSchema.parse({ + ...topup, + purchase_limit: topup.purchase_limit + ? { + interval: topup.purchase_limit.interval, + interval_count: topup.purchase_limit.interval_count ?? 1, + limit: topup.purchase_limit.limit, + } + : undefined, + }), + ); +}; + +export const pickBillingControlColumns = ( + source: Partial | null | undefined, +): Partial => { + if (!source) return {}; + + const picked = Object.fromEntries( + BILLING_CONTROL_KEYS.flatMap((key) => + source[key] === undefined ? [] : [[key, source[key]]], + ), + ) as Partial; + + if (picked.auto_topups !== undefined) { + picked.auto_topups = stripAutoTopupCountsForStorage( + picked.auto_topups as AutoTopupParams[] | null | undefined, + ); + } + + return picked; +}; + +export const billingControlsFromColumns = ( + source: Partial | null | undefined, +): CustomerBillingControls => { + if (!source) return {}; + + return Object.fromEntries( + BILLING_CONTROL_KEYS.flatMap((key) => + source[key] == null ? [] : [[key, source[key]]], + ), + ) as CustomerBillingControls; +}; + +/** Canonical form for change detection: skip_overage_billing false ≡ unset. */ +export const normalizeBillingControlsForCompare = ( + billingControls: CustomerBillingControls | null | undefined, +): CustomerBillingControls | undefined => { + if (!billingControls?.spend_limits) return billingControls ?? undefined; + + return { + ...billingControls, + spend_limits: billingControls.spend_limits.map((spendLimit) => { + if (spendLimit.skip_overage_billing === true) return spendLimit; + const { skip_overage_billing: _dropped, ...rest } = spendLimit; + return rest; + }), + }; +}; + +export const mergeBillingControls = ( + current: CustomerBillingControls | null | undefined, + patch: CustomerBillingControls | null | undefined, +): CustomerBillingControls | undefined => { + if (!patch) return current ?? undefined; + + return billingControlsFromColumns({ + ...(current ?? {}), + ...pickBillingControlColumns(patch), + }); +}; + export const CustomerBillingControlsParamsSchema = - CustomerBillingControlsSchema.check((ctx) => { + CustomerBillingControlsSchema.extend({ + auto_topups: z.array(AutoTopupParamsSchema).optional().meta({ + description: "List of auto top-up configurations per feature.", + }), + }).check((ctx) => { const billingControls = ctx.value; + const autoTopupFeatureIds = new Set(); + + for (const [index, autoTopup] of ( + billingControls.auto_topups ?? [] + ).entries()) { + if (autoTopupFeatureIds.has(autoTopup.feature_id)) { + ctx.issues.push({ + code: "custom", + message: "Only one auto top-up entry is allowed per feature_id", + input: autoTopup.feature_id, + path: ["auto_topups", index, "feature_id"], + }); + return; + } + + autoTopupFeatureIds.add(autoTopup.feature_id); + } + const spendLimitFeatureIds = new Set(); for (const [index, spendLimit] of ( @@ -263,17 +348,6 @@ export const CustomerBillingControlsParamsSchema = } }); -export type AutoTopupPurchaseLimit = z.infer< - typeof AutoTopupPurchaseLimitSchema ->; -export type ExpandedPurchaseLimit = z.infer; -export type AutoTopup = z.infer; -export type AutoTopupResponse = z.infer; -export type CustomerBillingControls = z.infer< - typeof CustomerBillingControlsSchema ->; -export type DbBillingControls = z.infer; - export type CustomerBillingControlsParams = z.input< typeof CustomerBillingControlsParamsSchema >; diff --git a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts index c1b7a24cf9..bb1419f93b 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts @@ -48,6 +48,7 @@ export const CustomerEntitlementSchema = z.object({ is_pooled_balance: z.boolean().optional(), pooled_balance_id: z.string().nullable().optional(), pooled_contribution_id: z.string().nullable().optional(), + reset_by_invoice: z.boolean().nullable().optional(), reset_cycle_anchor: z.number().nullable().optional(), next_reset_at: z.number().nullable(), adjustment: z.number().nullish().default(0), diff --git a/shared/models/cusProductModels/cusEntModels/cusEntTable.ts b/shared/models/cusProductModels/cusEntModels/cusEntTable.ts index 44c285d792..2d7fe688d6 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntTable.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntTable.ts @@ -62,6 +62,11 @@ export const customerEntitlements = pgTable( // Denormalized parent-product expiry; nullable tri-state so a manual `false` // stays sticky and the backfill cron only flips NULL -> true. expired: boolean("expired"), + + // Denormalized "invoice.created owns this reset" (price-backed on a live + // subscription). Written lazily by the batch reset worker's verdicts; + // filters these rows out of the reset scan so they stop re-entering it. + reset_by_invoice: boolean("reset_by_invoice"), }, (table) => [ foreignKey({ @@ -134,6 +139,17 @@ export const customerEntitlements = pgTable( sql`${table.expired} IS NOT TRUE AND ${table.next_reset_at} IS NOT NULL`, ) .concurrently(), + index("idx_customer_entitlements_pooled_contribution") + .on(table.pooled_contribution_id) + .where(sql`${table.pooled_contribution_id} IS NOT NULL`) + .concurrently(), + // Covers the stable reset predicate; expiry and pooled sources are heap-filtered. + index("idx_customer_entitlements_reset_scan") + .on(table.next_reset_at, sql`${table.id} COLLATE "C"`) + .where( + sql`${table.expired} IS NOT TRUE AND ${table.reset_by_invoice} IS NOT TRUE AND ${table.next_reset_at} IS NOT NULL`, + ) + .concurrently(), ], ); diff --git a/shared/utils/cusEntUtils/balanceUtils.ts b/shared/utils/cusEntUtils/balanceUtils.ts index b9a0844e6a..654f793e71 100644 --- a/shared/utils/cusEntUtils/balanceUtils.ts +++ b/shared/utils/cusEntUtils/balanceUtils.ts @@ -3,6 +3,7 @@ import { Decimal } from "decimal.js"; import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { notNullish, nullish } from "../utils.js"; +import { cusEntToStartingBalance } from "./balanceUtils/cusEntToStartingBalance.js"; export const getSummedEntityBalances = ({ cusEnt, @@ -128,9 +129,10 @@ export const getMaxOverage = ({ if (!cusEnt.usage_allowed) return undefined; - const maxOverage = new Decimal(usageLimit) - .sub(cusEnt.entitlement.allowance || 0) - .toNumber(); + const includedUsage = cusEnt.pooled_balance + ? cusEntToStartingBalance({ cusEnt }) + : cusEnt.entitlement.allowance || 0; + const maxOverage = new Decimal(usageLimit).sub(includedUsage).toNumber(); return maxOverage; }; diff --git a/shared/utils/cusEntUtils/convertCusEntUtils.ts b/shared/utils/cusEntUtils/convertCusEntUtils.ts index 05c27dc132..d5d0bc5da8 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils.ts @@ -64,33 +64,34 @@ export const cusEntToIncludedUsage = ({ entityId?: string; withRollovers?: boolean; }) => { - if (!cusEnt.customer_product) return 0; - const rollover = getRolloverFields({ cusEnt, entityId, }); - const { count: entityCount } = getCusEntBalance({ - cusEnt, - entityId, - }); - - const cusProduct = cusEnt.customer_product; - const options = entToOptions({ - ent: cusEnt.entitlement, - options: cusProduct?.options ?? [], - }); - - const cusPrice = cusEntToCusPrice({ cusEnt }); - const startingBalance = getStartingBalance({ - entitlement: cusEnt.entitlement, - options: options || undefined, - relatedPrice: cusPrice?.price, - productQuantity: cusProduct?.quantity ?? 1, - }); - - const total = new Decimal(startingBalance).mul(entityCount).toNumber(); + let total = cusEnt.pooled_balance?.granted; + if (total === undefined) { + if (!cusEnt.customer_product) return 0; + + const { count: entityCount } = getCusEntBalance({ + cusEnt, + entityId, + }); + const cusProduct = cusEnt.customer_product; + const options = entToOptions({ + ent: cusEnt.entitlement, + options: cusProduct.options ?? [], + }); + const cusPrice = cusEntToCusPrice({ cusEnt }); + const startingBalance = getStartingBalance({ + entitlement: cusEnt.entitlement, + options: options || undefined, + relatedPrice: cusPrice?.price, + productQuantity: cusProduct.quantity ?? 1, + }); + + total = new Decimal(startingBalance).mul(entityCount).toNumber(); + } if (withRollovers && rollover) { return new Decimal(total) @@ -122,5 +123,4 @@ export const cusEntToInternalProductId = ({ cusEnt, }: { cusEnt: FullCusEntWithFullCusProduct; -}): string | null => - cusEnt.customer_product?.internal_product_id ?? null; +}): string | null => cusEnt.customer_product?.internal_product_id ?? null; diff --git a/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts b/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts index f470962b61..1681ee9bcc 100644 --- a/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts +++ b/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts @@ -1,9 +1,11 @@ import { isEntityCusEnt } from "@utils/cusEntUtils/cusEntUtils.js"; +import { isCustomerProductLicenseAssignment } from "@utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.js"; import type { Entity } from "../../../models/cusModels/entityModels/entityModels.js"; import type { FullCustomer } from "../../../models/cusModels/fullCusModel.js"; import type { CustomerEntitlementFilters } from "../../../models/cusProductModels/cusEntModels/cusEntModels.js"; import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { CusProductStatus } from "../../../models/cusProductModels/cusProductEnums.js"; +import { isPooledBalanceSourceCustomerEntitlement } from "../../cusEntUtils/classifyCusEnt/isPooledBalanceCustomerEntitlement.js"; import { cusEntMatchesEntity } from "../../cusEntUtils/filterCusEntUtils.js"; import { sortCusEntsForDeduction } from "../../cusEntUtils/sortCusEntsForDeduction.js"; import { notNullish } from "../../utils.js"; @@ -55,6 +57,11 @@ export const fullCustomerToCustomerEntitlements = ({ }); } + cusEnts = cusEnts.filter( + (customerEntitlement) => + !isPooledBalanceSourceCustomerEntitlement({ customerEntitlement }), + ); + if (featureId) { cusEnts = cusEnts.filter( (cusEnt) => cusEnt.entitlement.feature.id === featureId, @@ -67,12 +74,23 @@ export const fullCustomerToCustomerEntitlements = ({ ); } - cusEnts = cusEnts.filter((cusEnt) => - cusEntMatchesEntity({ - cusEnt: cusEnt, - entity, - }), - ); + if (entity) { + cusEnts = cusEnts.filter((cusEnt) => + cusEntMatchesEntity({ + cusEnt: cusEnt, + entity, + }), + ); + } else { + // License seat assignments stay invisible at the customer level — the + // pool parent already reports them. Other entity-scoped cusEnts pool up. + cusEnts = cusEnts.filter( + (cusEnt) => + !isCustomerProductLicenseAssignment( + cusEnt.customer_product ?? undefined, + ), + ); + } // Filter out expired entitlements (applies to loose entitlements with expires_at) // This is necessary because cached fullCustomer may contain entitlements that have since expired diff --git a/shared/utils/fullSubjectUtils/fullSubjectToCustomerEntitlements.ts b/shared/utils/fullSubjectUtils/fullSubjectToCustomerEntitlements.ts index 1a923ef85e..2c2a06e3f6 100644 --- a/shared/utils/fullSubjectUtils/fullSubjectToCustomerEntitlements.ts +++ b/shared/utils/fullSubjectUtils/fullSubjectToCustomerEntitlements.ts @@ -3,6 +3,7 @@ import type { FullSubject } from "../../models/cusModels/fullSubject/fullSubject import type { CustomerEntitlementFilters } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; +import { isPooledBalanceSourceCustomerEntitlement } from "../cusEntUtils/classifyCusEnt/isPooledBalanceCustomerEntitlement.js"; import { cusEntMatchesEntity } from "../cusEntUtils/filterCusEntUtils.js"; import { sortCusEntsForDeduction } from "../cusEntUtils/sortCusEntsForDeduction.js"; import { notNullish } from "../utils.js"; @@ -47,6 +48,11 @@ export const fullSubjectToCustomerEntitlements = ({ }); } + customerEntitlements = customerEntitlements.filter( + (customerEntitlement) => + !isPooledBalanceSourceCustomerEntitlement({ customerEntitlement }), + ); + if (featureIds) { customerEntitlements = customerEntitlements.filter((customerEntitlement) => featureIds.includes(customerEntitlement.entitlement.feature.id), diff --git a/shared/utils/planV1Utils/diff/diffPlanV1.ts b/shared/utils/planV1Utils/diff/diffPlanV1.ts index 7f6c59dec3..1af00e23c0 100644 --- a/shared/utils/planV1Utils/diff/diffPlanV1.ts +++ b/shared/utils/planV1Utils/diff/diffPlanV1.ts @@ -87,7 +87,7 @@ export const toCreatePlanItemParams = ( const out: CreatePlanItemParamsV1 = { feature_id: item.feature_id }; if (item.entity_feature_id !== undefined) out.entity_feature_id = item.entity_feature_id; - if (item.pooled === true) out.pooled = true; + if (item.pooled !== undefined) out.pooled = item.pooled; if (includeInternalIds && item.entitlement_id !== undefined) out.entitlement_id = item.entitlement_id; if (includeInternalIds && item.price_id !== undefined) diff --git a/shared/utils/pooledBalanceUtils/convertPooledBalance/rolloverConfigToSignature.test.ts b/shared/utils/pooledBalanceUtils/convertPooledBalance/rolloverConfigToSignature.test.ts new file mode 100644 index 0000000000..dbba1c753a --- /dev/null +++ b/shared/utils/pooledBalanceUtils/convertPooledBalance/rolloverConfigToSignature.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { RolloverExpiryDurationType } from "../../../models/productModels/durationTypes/rolloverExpiryDurationType.js"; +import { rolloverConfigToSignature } from "./rolloverConfigToSignature.js"; + +test("rollover signatures ignore object shape and nullish optional fields", () => { + const compact = rolloverConfigToSignature({ + rollover: { + max_percentage: 50, + duration: RolloverExpiryDurationType.Month, + length: 1, + }, + }); + const explicit = rolloverConfigToSignature({ + rollover: { + length: 1, + max: null, + duration: RolloverExpiryDurationType.Month, + max_percentage: 50, + }, + }); + + expect(compact).toBe(explicit); + expect(rolloverConfigToSignature({ rollover: null })).toBe("none"); + expect(rolloverConfigToSignature({ rollover: undefined })).toBe("none"); +}); + +test("rollover signatures preserve valid zero values", () => { + const zeroMax = rolloverConfigToSignature({ + rollover: { + max: 0, + duration: RolloverExpiryDurationType.Forever, + length: 0, + }, + }); + const nullMax = rolloverConfigToSignature({ + rollover: { + max: null, + duration: RolloverExpiryDurationType.Forever, + length: 0, + }, + }); + + expect(zeroMax).not.toBe(nullMax); + expect(zeroMax).toContain("max=0"); + expect(zeroMax).toContain("length=0"); +}); diff --git a/shared/utils/pooledBalanceUtils/convertPooledBalance/rolloverConfigToSignature.ts b/shared/utils/pooledBalanceUtils/convertPooledBalance/rolloverConfigToSignature.ts new file mode 100644 index 0000000000..1943a4c91f --- /dev/null +++ b/shared/utils/pooledBalanceUtils/convertPooledBalance/rolloverConfigToSignature.ts @@ -0,0 +1,20 @@ +import { RolloverExpiryDurationType } from "../../../models/productModels/durationTypes/rolloverExpiryDurationType.js"; +import type { RolloverConfig } from "../../../models/productV2Models/productItemModels/productItemModels.js"; + +const numberToSignaturePart = (value: number | null | undefined) => + value === null || value === undefined ? "null" : String(value); + +export const rolloverConfigToSignature = ({ + rollover, +}: { + rollover?: RolloverConfig | null; +}): string => { + if (rollover === null || rollover === undefined) return "none"; + + return [ + `max=${numberToSignaturePart(rollover.max)}`, + `max_percentage=${numberToSignaturePart(rollover.max_percentage)}`, + `duration=${rollover.duration ?? RolloverExpiryDurationType.Month}`, + `length=${numberToSignaturePart(rollover.length)}`, + ].join(";"); +}; diff --git a/shared/utils/pooledBalanceUtils/index.ts b/shared/utils/pooledBalanceUtils/index.ts index eccd00c674..70da8a7a91 100644 --- a/shared/utils/pooledBalanceUtils/index.ts +++ b/shared/utils/pooledBalanceUtils/index.ts @@ -1,2 +1,3 @@ export * from "./convertPooledBalance/pooledBalanceIdentityToKey.js"; export * from "./convertPooledBalance/pooledBalanceToPooledBalanceIdentity.js"; +export * from "./convertPooledBalance/rolloverConfigToSignature.js"; diff --git a/shared/utils/productV2Utils/compareProductUtils/compareItemUtils.ts b/shared/utils/productV2Utils/compareProductUtils/compareItemUtils.ts index 71b51d6ee6..b933d10a23 100644 --- a/shared/utils/productV2Utils/compareProductUtils/compareItemUtils.ts +++ b/shared/utils/productV2Utils/compareProductUtils/compareItemUtils.ts @@ -40,6 +40,7 @@ export const findSimilarItem = ({ (i) => isFeatureItem(i) && i.feature_id === item.feature_id && + i.entity_feature_id == item.entity_feature_id && entIntervalsSame({ intervalA: { interval: itemToEntInterval({ item: i }), @@ -58,6 +59,7 @@ export const findSimilarItem = ({ (i) => isFeaturePriceItem(i) && i.feature_id === item.feature_id && + i.entity_feature_id == item.entity_feature_id && intervalsSame({ intervalA: { interval: itemToBillingInterval({ item: i }), diff --git a/shared/utils/productV2Utils/mapToProductV2.test.ts b/shared/utils/productV2Utils/mapToProductV2.test.ts new file mode 100644 index 0000000000..d33d552f51 --- /dev/null +++ b/shared/utils/productV2Utils/mapToProductV2.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from "bun:test"; +import { mapToProductItems } from "./mapToProductV2.js"; + +// Legacy/customized products may omit relational arrays; mapping must remain total. +test("mapToProductItems normalizes missing product relations", () => { + expect( + mapToProductItems({ + prices: undefined as never, + entitlements: undefined as never, + features: [], + }), + ).toEqual([]); +}); diff --git a/shared/utils/productV2Utils/mapToProductV2.ts b/shared/utils/productV2Utils/mapToProductV2.ts index 816f4766ac..bba4707365 100644 --- a/shared/utils/productV2Utils/mapToProductV2.ts +++ b/shared/utils/productV2Utils/mapToProductV2.ts @@ -19,6 +19,8 @@ export const mapToProductItems = ({ features: Feature[]; }): ProductItem[] => { const items: ProductItem[] = []; + prices ??= []; + entitlements ??= []; for (const ent of entitlements) { // const relatedPrice = getEntRelatedPrice(ent, prices, allowFeatureMatch); diff --git a/vite/src/components/forms/attach-v2/components/AttachLicenseLossWarning.tsx b/vite/src/components/forms/attach-v2/components/AttachLicenseLossWarning.tsx new file mode 100644 index 0000000000..238d63dfa0 --- /dev/null +++ b/vite/src/components/forms/attach-v2/components/AttachLicenseLossWarning.tsx @@ -0,0 +1,52 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@autumn/ui"; +import { CaretDownIcon, UserIcon } from "@phosphor-icons/react"; +import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; +import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox"; +import { useLicenseLossEntities } from "../hooks/useLicenseLossEntities"; + +export function AttachLicenseLossWarning() { + const entities = useLicenseLossEntities(); + if (entities.length === 0) return null; + + const isSingle = entities.length === 1; + + return ( + + + + + + {entities.length} {isSingle ? "entity" : "entities"} + {" "} + will lose {isSingle ? "its" : "their"} licenses when this plan + change takes effect.{" "} + + View entities + + + + +
    + {entities.map((entity) => ( +
  • + + {entity.label} +
  • + ))} +
+
+
+
+
+ ); +} diff --git a/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts b/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts index f2a0d8e8d5..ac7d10d1b0 100644 --- a/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts +++ b/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts @@ -162,8 +162,8 @@ export function buildAttachRequestBody({ if (startDate && !trialEnabled) { body.starts_at = startDate; - } else if (planSchedule) { - body.plan_schedule = planSchedule; + } else { + body.plan_schedule = planSchedule ?? "immediate"; } if (endDate) { diff --git a/vite/src/components/forms/attach-v2/hooks/useLicenseLossEntities.ts b/vite/src/components/forms/attach-v2/hooks/useLicenseLossEntities.ts new file mode 100644 index 0000000000..a8085d32d3 --- /dev/null +++ b/vite/src/components/forms/attach-v2/hooks/useLicenseLossEntities.ts @@ -0,0 +1,45 @@ +import type { FullCustomer } from "@autumn/shared"; +import { useLicenseBalancesQuery } from "@/hooks/queries/useLicenseBalancesQuery"; +import { usePlanLicensesQuery } from "@/hooks/queries/usePlanLicensesQuery"; +import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; +import { useAttachFormContext } from "../context/AttachFormProvider"; + +export type LicenseLossEntity = { id: string; label: string }; + +/** Returns affected entities for complete license removal only. */ +export function useLicenseLossEntities(): LicenseLossEntity[] { + const { customerId, product, formValues, previewDiff } = + useAttachFormContext(); + const { planLicenses } = usePlanLicensesQuery(product?.id); + const { assignments } = useLicenseBalancesQuery({ customerId }); + const { customer } = useCusQuery(); + + const keepsAnyLicense = + planLicenses.length + (formValues.addLicenses?.length ?? 0) > 0; + if (keepsAnyLicense) return []; + + const outgoingLicenseIds = new Set( + previewDiff.outgoingLicenses.map((license) => license.license_plan_id), + ); + if (outgoingLicenseIds.size === 0) return []; + + const entityById = new Map( + ((customer as FullCustomer | null)?.entities ?? []).map((entity) => [ + entity.id, + entity, + ]), + ); + + const labelById = new Map(); + for (const assignment of assignments) { + if (!outgoingLicenseIds.has(assignment.license_plan_id)) continue; + if (labelById.has(assignment.entity_id)) continue; + const entity = entityById.get(assignment.entity_id); + labelById.set( + assignment.entity_id, + entity?.name || entity?.id || assignment.entity_id, + ); + } + + return [...labelById].map(([id, label]) => ({ id, label })); +} diff --git a/vite/src/components/forms/attach-v2/index.ts b/vite/src/components/forms/attach-v2/index.ts index cd8ca28ec6..683cf69cfc 100644 --- a/vite/src/components/forms/attach-v2/index.ts +++ b/vite/src/components/forms/attach-v2/index.ts @@ -5,6 +5,7 @@ export * from "./attachFormSchema"; export * from "./components/AttachAdvancedSection"; export * from "./components/AttachFooter"; export * from "./components/AttachFooterV3"; +export * from "./components/AttachLicenseLossWarning"; export * from "./components/AttachPlanOptions"; export * from "./components/AttachPlanSection"; export * from "./components/AttachPreviewSection"; @@ -21,6 +22,7 @@ export * from "./hooks/useAttachMutation"; export * from "./hooks/useAttachPreview"; export * from "./hooks/useAttachRequestBody"; export * from "./hooks/useGrantFree"; +export * from "./hooks/useLicenseLossEntities"; export * from "./hooks/usePreviewDiff"; // Utils diff --git a/vite/src/components/forms/shared/UrlSuccessView.tsx b/vite/src/components/forms/shared/UrlSuccessView.tsx index 1c326d7819..1b45d54a44 100644 --- a/vite/src/components/forms/shared/UrlSuccessView.tsx +++ b/vite/src/components/forms/shared/UrlSuccessView.tsx @@ -46,8 +46,8 @@ export function UrlSuccessView({ diff --git a/vite/src/hooks/queries/useMigrationsQuery.tsx b/vite/src/hooks/queries/useMigrationsQuery.tsx index a93c5ebe82..5dccd6bd28 100644 --- a/vite/src/hooks/queries/useMigrationsQuery.tsx +++ b/vite/src/hooks/queries/useMigrationsQuery.tsx @@ -103,7 +103,6 @@ export const useMigrationsQuery = () => { dry_run?: boolean; limit?: number; only?: string[]; - concurrency?: number; lazy_run?: boolean; retry_item_statuses?: RetryableMigrationItemRunStatus[]; }) => { @@ -111,7 +110,7 @@ export const useMigrationsQuery = () => { migration_id: string; dry_run: boolean; lazy_run: boolean; - concurrency?: number; + concurrency: number; run_id: string; trigger_run_id?: string; public_access_token?: string; diff --git a/vite/src/views/admin/AdminView.tsx b/vite/src/views/admin/AdminView.tsx index dd5cb8512e..83991caf3a 100644 --- a/vite/src/views/admin/AdminView.tsx +++ b/vite/src/views/admin/AdminView.tsx @@ -1,6 +1,6 @@ import { Button, Tabs, TabsContent, TabsList, TabsTrigger } from "@autumn/ui"; import { Globe, Sliders } from "@phosphor-icons/react"; -import { useState } from "react"; +import { parseAsStringLiteral, useQueryState } from "nuqs"; import { useNavigate } from "react-router"; import { toast } from "sonner"; import { authClient } from "@/lib/auth-client"; @@ -12,19 +12,33 @@ import { DefaultView } from "../DefaultView"; import LoadingScreen from "../general/LoadingScreen"; import { CreateUser } from "./components/CreateUser"; import { EdgeConfigTab } from "./components/EdgeConfigTab"; +import { QueueCronConfigsTab } from "./components/QueueCronConfigsTab"; import { SlackAdminBotTab } from "./components/SlackAdminBotTab"; import { useAdmin } from "./hooks/useAdmin"; +const ADMIN_TAB_IDS = [ + "orgs", + "users", + "slack-bot", + "edge-config", + "queue-cron-configs", +] as const; + +type AdminTab = (typeof ADMIN_TAB_IDS)[number]; + export const AdminView = () => { const navigate = useNavigate(); const env = useEnv(); const { isAdmin, isPending } = useAdmin(); const adminBasePath = getRedirectUrl("/admin", env); - const [activeTab, setActiveTab] = useState("orgs"); + const [activeTab, setActiveTab] = useQueryState( + "tab", + parseAsStringLiteral(ADMIN_TAB_IDS).withDefault("orgs"), + ); if (isPending) { return ( -
+
); @@ -77,12 +91,18 @@ export const AdminView = () => {
- + setActiveTab(value as AdminTab)} + > Organizations Users Slack Bot Edge Config + + Queue / Cron configs + @@ -100,6 +120,10 @@ export const AdminView = () => { + + + + ); diff --git a/vite/src/views/admin/components/EdgeConfigTab.tsx b/vite/src/views/admin/components/EdgeConfigTab.tsx index 4dd0a12416..0ba39f5805 100644 --- a/vite/src/views/admin/components/EdgeConfigTab.tsx +++ b/vite/src/views/admin/components/EdgeConfigTab.tsx @@ -7,7 +7,7 @@ import { CustomerBlockDialog } from "./CustomerBlockDialog"; import { EdgeConfigDialog } from "./EdgeConfigDialog"; import { FeatureFlagsDialog } from "./FeatureFlagsDialog"; import { FullSubjectGateDialog } from "./FullSubjectGateDialog"; -import { JobQueuesDialog } from "./JobQueuesDialog"; +import { MainRedisCacheDialog } from "./MainRedisCacheDialog"; import { MiscellaneousEdgeConfigDialog } from "./MiscellaneousEdgeConfigDialog"; import { OrgLimitsDialog } from "./OrgLimitsDialog"; import { RateLimitOverridesDialog } from "./RateLimitOverridesDialog"; @@ -36,9 +36,9 @@ export function EdgeConfigTab() { const [rateLimitOverridesOpen, setRateLimitOverridesOpen] = useState(false); const [rateLimitRedisAllowlistOpen, setRateLimitRedisAllowlistOpen] = useState(false); - const [jobQueuesOpen, setJobQueuesOpen] = useState(false); const [stripeSyncOpen, setStripeSyncOpen] = useState(false); const [redisV2CacheOpen, setRedisV2CacheOpen] = useState(false); + const [mainRedisCacheOpen, setMainRedisCacheOpen] = useState(false); const [cacheV2RampOpen, setCacheV2RampOpen] = useState(false); const [miscellaneousOpen, setMiscellaneousOpen] = useState(false); const [fullSubjectGateOpen, setFullSubjectGateOpen] = useState(false); @@ -222,17 +222,16 @@ export function EdgeConfigTab() {
- Job Queues + Stripe Sync
- Pause or resume worker consumption for shared and dedicated SQS - queues. + Enable Stripe webhook event syncing to the sync DB per org.
@@ -241,16 +240,17 @@ export function EdgeConfigTab() {
- Stripe Sync + V2 Redis Instance
- Enable Stripe webhook event syncing to the sync DB per org. + Switch the active V2 Redis between upstash, redis, and dragonfly + at runtime.
@@ -259,17 +259,17 @@ export function EdgeConfigTab() {
- V2 Redis Instance + Main Redis Instance
-
- Switch the active V2 Redis between upstash, redis, and dragonfly - at runtime. +
+ Globally switch auth, idempotency, rate-limit, lock, and legacy + cache traffic between CACHE_URL and CACHE_BACKUP_URL.
@@ -369,8 +369,6 @@ export function EdgeConfigTab() { onOpenChange={setRateLimitRedisAllowlistOpen} /> - - + + void; +}) => { + const axiosInstance = useAxiosInstance(); + const configQuery = useQuery({ + queryKey: LAZY_BATCH_RESET_QUERY_KEY, + queryFn: async () => { + const { data } = await axiosInstance.get("/admin/batch-reset-config"); + return data; + }, + enabled: open, + }); + + return ( + + + + Lazy Batch Resets + + Control request-triggered background entitlement repairs. + + + + {configQuery.isLoading ? ( +
+ + +
+ ) : configQuery.isError || !configQuery.data ? ( +
+

+ {getBackendErr( + configQuery.error, + "Failed to load lazy batch reset config", + )} +

+ +
+ ) : ( + onOpenChange(false)} + /> + )} +
+
+ ); +}; diff --git a/vite/src/views/admin/components/LazyBatchResetConfigForm.tsx b/vite/src/views/admin/components/LazyBatchResetConfigForm.tsx new file mode 100644 index 0000000000..8487931539 --- /dev/null +++ b/vite/src/views/admin/components/LazyBatchResetConfigForm.tsx @@ -0,0 +1,129 @@ +import { + Alert, + AlertDescription, + Badge, + Button, + DialogFooter, + Switch, +} from "@autumn/ui"; +import { useStore } from "@tanstack/react-form"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useAppForm } from "@/hooks/form/form"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr } from "@/utils/genUtils"; +import { + LAZY_BATCH_RESET_QUERY_KEY, + type LazyBatchResetConfig, +} from "./lazyBatchResetConfigTypes"; + +export const LazyBatchResetConfigForm = ({ + config, + onClose, +}: { + config: LazyBatchResetConfig; + onClose: () => void; +}) => { + const axiosInstance = useAxiosInstance(); + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: async (enabled: boolean) => { + await axiosInstance.put("/admin/batch-reset-config", { enabled }); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: LAZY_BATCH_RESET_QUERY_KEY, + }); + toast.success("Lazy batch reset config saved"); + onClose(); + }, + onError: (error) => { + toast.error( + getBackendErr(error, "Failed to save lazy batch reset config"), + ); + }, + }); + const form = useAppForm({ + defaultValues: { enabled: config.enabled }, + onSubmit: async ({ value }) => { + await mutation.mutateAsync(value.enabled); + }, + }); + const enabled = useStore(form.store, (state) => state.values.enabled); + const isDirty = useStore(form.store, (state) => state.isDirty); + + return ( + <> +
+
+
+
+ Lazy batch resets enabled +
+
+ When disabled, producers stop enqueueing lazy entitlement repairs + and workers skip queued jobs. +
+
+ + {(field) => ( + + )} + +
+ + {!enabled && ( + + + Customer and entity list requests will stop scheduling background + entitlement repairs. Already queued jobs will be acknowledged + without running. + + + )} + +
+
+ + {config.configHealthy ? "Config healthy" : "Config unavailable"} + + {config.lastSuccessAt && ( + + Last refresh: {new Date(config.lastSuccessAt).toLocaleString()} + + )} +
+
+ {config.configConfigured === false + ? "S3 lazy batch reset config is not configured. Lazy resets default to enabled." + : config.error || + "Changes propagate to servers and workers within 10 seconds."} +
+
+
+ + + {mutation.error && ( + + {getBackendErr(mutation.error, "Failed to save config")} + + )} + + + + + ); +}; diff --git a/vite/src/views/admin/components/MainRedisCacheDialog.tsx b/vite/src/views/admin/components/MainRedisCacheDialog.tsx new file mode 100644 index 0000000000..47027fdcec --- /dev/null +++ b/vite/src/views/admin/components/MainRedisCacheDialog.tsx @@ -0,0 +1,259 @@ +import { + Alert, + AlertAction, + AlertDescription, + AlertTitle, + Badge, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@autumn/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useAppForm } from "@/hooks/form/form"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr } from "@/utils/genUtils"; + +const INSTANCE_OPTIONS = [ + { + value: "primary", + label: "Primary", + description: "Regional CACHE_URL endpoints", + }, + { + value: "fallback", + label: "Fallback", + description: "Global CACHE_BACKUP_URL endpoint", + }, +] as const; + +type InstanceName = (typeof INSTANCE_OPTIONS)[number]["value"]; + +type MainRedisCacheConfig = { + activeInstance: InstanceName; + fallbackConfigured: boolean; + fallbackStatus: string; + configHealthy: boolean; + configConfigured: boolean; + lastSuccessAt: string | null; + error: string | null; +}; + +const QUERY_KEY = ["admin-edge-config", "main-redis-cache"]; + +function MainRedisCacheForm({ + config, + onSaved, +}: { + config: MainRedisCacheConfig; + onSaved: () => void; +}) { + const axiosInstance = useAxiosInstance(); + const queryClient = useQueryClient(); + const saveMutation = useMutation({ + mutationFn: async (activeInstance: InstanceName) => { + await axiosInstance.put("/admin/main-redis-cache-config", { + activeInstance, + }); + }, + onSuccess: async (_data, activeInstance) => { + await queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success(`Active main Redis set to "${activeInstance}"`); + onSaved(); + }, + onError: (error) => { + toast.error(getBackendErr(error, "Failed to switch main Redis")); + }, + }); + const form = useAppForm({ + defaultValues: { activeInstance: config.activeInstance }, + onSubmit: async ({ value }) => { + await saveMutation.mutateAsync(value.activeInstance); + }, + }); + + return ( + <> +
+ + {(field) => ( +
+
+ Active Instance +
+ +
+ )} +
+ + + Connection state is not copied + + The switch only reroutes commands. Use a synchronized fallback when + preserving existing locks and idempotency keys is required. + + + +
+
+ + {config.configHealthy ? "Config healthy" : "Config unavailable"} + + + Fallback:{" "} + {config.fallbackConfigured + ? config.fallbackStatus + : "not configured"} + + {config.lastSuccessAt && ( + + Last refresh: {new Date(config.lastSuccessAt).toLocaleString()} + + )} +
+
+ {config.configConfigured === false + ? "S3 main Redis config is not configured. Traffic defaults to primary." + : config.error || + "Changes propagate to servers, workers, and cron within 10 seconds."} +
+
+
+ + + + ({ + activeInstance: state.values.activeInstance, + isSubmitting: state.isSubmitting, + })} + > + {({ activeInstance, isSubmitting }) => ( + + )} + + + + ); +} + +export function MainRedisCacheDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const axiosInstance = useAxiosInstance(); + const configQuery = useQuery({ + queryKey: QUERY_KEY, + queryFn: async () => { + const { data } = await axiosInstance.get( + "/admin/main-redis-cache-config", + ); + return data; + }, + enabled: open, + refetchInterval: open ? 2_000 : false, + }); + + return ( + + + + + Main Redis Instance + + + Globally route legacy cache, auth, idempotency, rate-limit, and lock + traffic between the primary and fallback Redis. + + + + {configQuery.isLoading ? ( +
+ Loading... +
+ ) : configQuery.isError ? ( + + Failed to load main Redis config + + {getBackendErr(configQuery.error, "Please try again")} + + + + + + ) : configQuery.data ? ( + onOpenChange(false)} + /> + ) : null} +
+
+ ); +} diff --git a/vite/src/views/admin/components/MiscellaneousEdgeConfigDialog.tsx b/vite/src/views/admin/components/MiscellaneousEdgeConfigDialog.tsx index b00df9fe80..9742ded034 100644 --- a/vite/src/views/admin/components/MiscellaneousEdgeConfigDialog.tsx +++ b/vite/src/views/admin/components/MiscellaneousEdgeConfigDialog.tsx @@ -7,6 +7,7 @@ import { DialogFooter, DialogHeader, DialogTitle, + Switch, } from "@autumn/ui"; import Editor from "@monaco-editor/react"; import { useEffect, useState } from "react"; @@ -16,6 +17,7 @@ import { getBackendErr } from "@/utils/genUtils"; type MiscellaneousEdgeConfig = { newFlatCusModel: string[]; + syncCoalesce: boolean; configHealthy: boolean; configConfigured: boolean; lastSuccessAt: string | null; @@ -24,6 +26,7 @@ type MiscellaneousEdgeConfig = { const DEFAULT_CONFIG: MiscellaneousEdgeConfig = { newFlatCusModel: [], + syncCoalesce: false, configHealthy: false, configConfigured: false, lastSuccessAt: null, @@ -61,6 +64,7 @@ export function MiscellaneousEdgeConfigDialog({ ...DEFAULT_CONFIG, ...data, newFlatCusModel: data.newFlatCusModel ?? [], + syncCoalesce: data.syncCoalesce ?? false, }; setConfig(merged); const { @@ -110,6 +114,7 @@ export function MiscellaneousEdgeConfigDialog({ setConfig((prev) => ({ ...prev, newFlatCusModel: parsed.newFlatCusModel ?? prev.newFlatCusModel, + syncCoalesce: parsed.syncCoalesce ?? prev.syncCoalesce, })); setJsonError(null); } catch { @@ -258,6 +263,25 @@ export function MiscellaneousEdgeConfigDialog({
+
+
+
+ Sync Coalescing +
+
+ Coalesce balance syncs via per-customer Redis dirty state; + SQS carries signal-only messages. Applies to all orgs. +
+
+ { + setSyncSource("form"); + setConfig((prev) => ({ ...prev, syncCoalesce })); + }} + /> +
+
void; +}) => ( +
+
+ {icon} +
+
+
{label}
+
{title}
+
+ {description} +
+
+ +
+); diff --git a/vite/src/views/admin/components/QueueCronConfigsTab.tsx b/vite/src/views/admin/components/QueueCronConfigsTab.tsx new file mode 100644 index 0000000000..c25da36edd --- /dev/null +++ b/vite/src/views/admin/components/QueueCronConfigsTab.tsx @@ -0,0 +1,89 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@autumn/ui"; +import { Clock, ListTodo, RefreshCw, Sparkles } from "lucide-react"; +import { useState } from "react"; +import { JobQueuesDialog } from "./JobQueuesDialog"; +import { LazyBatchResetConfigDialog } from "./LazyBatchResetConfigDialog"; +import { QueueCronConfigRow } from "./QueueCronConfigRow"; +import { ResetJobConfigDialog } from "./ResetJobConfigDialog"; +import { ResetJobV2ConfigDialog } from "./ResetJobV2ConfigDialog"; + +type ConfigDialog = + | "job-queues" + | "batch-reset-v2" + | "reset-job" + | "lazy-batch-resets"; + +export const QueueCronConfigsTab = () => { + const [activeDialog, setActiveDialog] = useState(null); + + return ( + <> + + + + Queue and cron controls + + + Tune worker consumption and balance reset pipelines without a + deploy. + + + + } + label="Worker infrastructure" + title="Job Queues" + description="Pause or resume worker consumption for shared and dedicated SQS queues." + onEdit={() => setActiveDialog("job-queues")} + /> + } + label="Primary reset cron" + title="Batch Reset V2" + description="Scan overdue customer entitlements and fan them out to reset workers." + onEdit={() => setActiveDialog("batch-reset-v2")} + /> + } + label="Legacy reset cron" + title="Reset Job" + description="Continuously reset due balances in small, serialized batches." + onEdit={() => setActiveDialog("reset-job")} + /> + } + label="Request-triggered repairs" + title="Lazy Batch Resets" + description="Control lazy entitlement repairs scheduled by customer and entity list requests." + onEdit={() => setActiveDialog("lazy-batch-resets")} + /> + + + + setActiveDialog(open ? "job-queues" : null)} + /> + setActiveDialog(open ? "batch-reset-v2" : null)} + /> + setActiveDialog(open ? "reset-job" : null)} + /> + + setActiveDialog(open ? "lazy-batch-resets" : null) + } + /> + + ); +}; diff --git a/vite/src/views/admin/components/RateLimitOverrideOrgCard.tsx b/vite/src/views/admin/components/RateLimitOverrideOrgCard.tsx new file mode 100644 index 0000000000..d5e00726c6 --- /dev/null +++ b/vite/src/views/admin/components/RateLimitOverrideOrgCard.tsx @@ -0,0 +1,71 @@ +import { + Badge, + Button, + Card, + CardAction, + CardContent, + CardHeader, + CardTitle, + Separator, +} from "@autumn/ui"; +import { Fragment } from "react"; + +export type RateLimitOverrideEntry = { + type: string; + limit: number; + defaultLimit: number | undefined; +}; + +export function RateLimitOverrideOrgCard({ + orgId, + entries, + onRemove, +}: { + orgId: string; + entries: RateLimitOverrideEntry[]; + onRemove: ({ orgId, type }: { orgId: string; type: string }) => void; +}) { + const countLabel = entries.length === 1 ? "limit" : "limits"; + + return ( + + + {orgId} + + + {entries.length} {countLabel} + + + + + {entries.map((entry, index) => ( + + {index > 0 && } +
+
+
+ {entry.type} +
+
+ limit: {entry.limit} + {entry.defaultLimit !== undefined && ( + + (default {entry.defaultLimit}) + + )} +
+
+ +
+
+ ))} +
+
+ ); +} diff --git a/vite/src/views/admin/components/RateLimitOverridesDialog.tsx b/vite/src/views/admin/components/RateLimitOverridesDialog.tsx index 72a82b4e73..548c6c6937 100644 --- a/vite/src/views/admin/components/RateLimitOverridesDialog.tsx +++ b/vite/src/views/admin/components/RateLimitOverridesDialog.tsx @@ -8,6 +8,7 @@ import { DialogHeader, DialogTitle, Input, + ScrollArea, Select, SelectContent, SelectItem, @@ -19,6 +20,10 @@ import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { getBackendErr } from "@/utils/genUtils"; +import { + type RateLimitOverrideEntry, + RateLimitOverrideOrgCard, +} from "./RateLimitOverrideOrgCard"; type RateLimitDefault = { limit: number; @@ -52,34 +57,36 @@ const getEditableConfig = ({ orgs: config.orgs, }); -type EntryRow = { +type OrgEntryGroup = { orgId: string; - type: string; - limit: number; - defaultLimit: number | undefined; + entries: RateLimitOverrideEntry[]; }; -const getEntryRows = ({ +const getOrgEntryGroups = ({ config, }: { config: RateLimitOverridesConfig; -}): EntryRow[] => { - const rows: EntryRow[] = []; - for (const [orgId, entry] of Object.entries(config.orgs)) { - for (const [type, limit] of Object.entries(entry.limits ?? {})) { - rows.push({ - orgId, +}): OrgEntryGroup[] => { + const groups: OrgEntryGroup[] = []; + const orgs = Object.entries(config.orgs); + orgs.sort(([leftOrgId], [rightOrgId]) => leftOrgId.localeCompare(rightOrgId)); + + for (const [orgId, entry] of orgs) { + const limits = Object.entries(entry.limits ?? {}); + limits.sort(([leftType], [rightType]) => leftType.localeCompare(rightType)); + + const entries: RateLimitOverrideEntry[] = []; + for (const [type, limit] of limits) { + entries.push({ type, limit, defaultLimit: config.defaults[type]?.limit, }); } + if (entries.length > 0) groups.push({ orgId, entries }); } - return rows.sort((a, b) => - a.orgId === b.orgId - ? a.type.localeCompare(b.type) - : a.orgId.localeCompare(b.orgId), - ); + + return groups; }; export function RateLimitOverridesDialog({ @@ -144,7 +151,7 @@ export function RateLimitOverridesDialog({ setJsonError(null); }, [config, syncSource]); - const entryRows = useMemo(() => getEntryRows({ config }), [config]); + const orgEntryGroups = useMemo(() => getOrgEntryGroups({ config }), [config]); const rateLimitTypes = useMemo( () => Object.keys(config.defaults).sort(), [config.defaults], @@ -232,7 +239,7 @@ export function RateLimitOverridesDialog({ return ( - + Rate Limit Overrides @@ -242,171 +249,146 @@ export function RateLimitOverridesDialog({ - {loading ? ( -
- Loading... -
- ) : ( -
-
-
- Override Entries -
- -
-
- setNewOrgId(event.target.value)} - /> - - setNewLimit(event.target.value)} - /> - + + {loading ? ( +
+ Loading... +
+ ) : ( +
+
+
+ Override Entries
-
- {entryRows.length === 0 ? ( -
- No overrides — all orgs use the hardcoded defaults. -
- ) : ( - entryRows.map((entry) => ( -
-
-
- {entry.orgId} -
-
- {entry.type} -
-
- limit: {entry.limit} - {entry.defaultLimit !== undefined && ( - - (default {entry.defaultLimit}) +
+
+ setNewOrgId(event.target.value)} + /> + + setNewLimit(event.target.value)} + /> + +
+ +
+ {orgEntryGroups.length === 0 ? ( +
+ No overrides — all orgs use the hardcoded defaults.
- )) - )} + ) : ( + orgEntryGroups.map(({ orgId, entries }) => ( + + )) + )} +
-
-
-
- - {config.configHealthy - ? "Config healthy" - : "Config unavailable"} - - {config.lastSuccessAt && ( - - Last refresh:{" "} - {new Date(config.lastSuccessAt).toLocaleString()} - - )} -
-
- {config.configConfigured === false - ? "S3 rate-limit overrides config is not configured." - : config.error || - "Overrides take effect on the next request after polling refresh (10s)."} +
+
+ + {config.configHealthy + ? "Config healthy" + : "Config unavailable"} + + {config.lastSuccessAt && ( + + Last refresh:{" "} + {new Date(config.lastSuccessAt).toLocaleString()} + + )} +
+
+ {config.configConfigured === false + ? "S3 rate-limit overrides config is not configured." + : config.error || + "Overrides take effect on the next request after polling refresh (10s)."} +
-
-
-
- Raw JSON -
-
- +
+
+ Raw JSON +
+
+ +
+ {jsonError && ( +
{jsonError}
+ )}
- {jsonError && ( -
{jsonError}
- )}
-
- )} + )} + + + + ) : ( + onOpenChange(false)} + /> + )} + +
+ ); +}; diff --git a/vite/src/views/admin/components/ResetJobConfigForm.tsx b/vite/src/views/admin/components/ResetJobConfigForm.tsx new file mode 100644 index 0000000000..8a004aa65b --- /dev/null +++ b/vite/src/views/admin/components/ResetJobConfigForm.tsx @@ -0,0 +1,129 @@ +import { Badge, Button, DialogFooter, Switch } from "@autumn/ui"; +import { useStore } from "@tanstack/react-form"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useAppForm } from "@/hooks/form/form"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr } from "@/utils/genUtils"; +import { + RESET_JOB_QUERY_KEY, + type ResetJobConfig, + type ResetJobFormValues, +} from "./resetJobConfigTypes"; + +const MAX_BATCH_SIZE = 2_000; + +export const ResetJobConfigForm = ({ + config, + onClose, +}: { + config: ResetJobConfig; + onClose: () => void; +}) => { + const axiosInstance = useAxiosInstance(); + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: async (nextConfig: ResetJobFormValues) => { + await axiosInstance.put("/admin/reset-job-config", nextConfig); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: RESET_JOB_QUERY_KEY }); + toast.success("Reset Job config saved"); + onClose(); + }, + onError: (error) => { + toast.error(getBackendErr(error, "Failed to save Reset Job config")); + }, + }); + const form = useAppForm({ + defaultValues: { + enabled: config.enabled, + batchSize: config.batchSize as number | null, + }, + onSubmit: async ({ value }) => { + if (value.batchSize === null) return; + await mutation.mutateAsync({ + enabled: value.enabled, + batchSize: value.batchSize, + }); + }, + }); + const isDirty = useStore(form.store, (state) => state.isDirty); + + return ( + <> +
+ + {(field) => ( +
+
+
+ Reset Job enabled +
+
+ When disabled, the legacy reset cron stops processing due + balances. +
+
+ +
+ )} +
+ + + {(field) => ( + + )} + + +
+
+ + {config.configHealthy ? "Config healthy" : "Config unavailable"} + + {config.lastSuccessAt && ( + + Last refresh: {new Date(config.lastSuccessAt).toLocaleString()} + + )} +
+
+ {config.configConfigured === false + ? "S3 Reset Job config is not configured. The job defaults to disabled." + : config.error || + "Changes propagate to cron instances within 10 seconds."} +
+
+
+ + + {mutation.error && ( + + {getBackendErr(mutation.error, "Failed to save config")} + + )} + + + + + ); +}; diff --git a/vite/src/views/admin/components/ResetJobV2ConfigDialog.tsx b/vite/src/views/admin/components/ResetJobV2ConfigDialog.tsx new file mode 100644 index 0000000000..64f8f97539 --- /dev/null +++ b/vite/src/views/admin/components/ResetJobV2ConfigDialog.tsx @@ -0,0 +1,79 @@ +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Skeleton, +} from "@autumn/ui"; +import { useQuery } from "@tanstack/react-query"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr } from "@/utils/genUtils"; +import { ResetJobV2ConfigForm } from "./ResetJobV2ConfigForm"; +import { + RESET_JOB_V2_QUERY_KEY, + type ResetJobV2Config, +} from "./resetJobV2ConfigTypes"; + +export const ResetJobV2ConfigDialog = ({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) => { + const axiosInstance = useAxiosInstance(); + const configQuery = useQuery({ + queryKey: RESET_JOB_V2_QUERY_KEY, + queryFn: async () => { + const { data } = await axiosInstance.get("/admin/reset-job-v2-config"); + return data; + }, + enabled: open, + }); + + return ( + + + + Batch Reset V2 + + Control reset scanning, worker fan-out, and runtime throughput. + + + + {configQuery.isLoading ? ( +
+ + + + +
+ ) : configQuery.isError || !configQuery.data ? ( +
+

+ {getBackendErr( + configQuery.error, + "Failed to load Batch Reset V2 config", + )} +

+ +
+ ) : ( + onOpenChange(false)} + /> + )} +
+
+ ); +}; diff --git a/vite/src/views/admin/components/ResetJobV2ConfigForm.tsx b/vite/src/views/admin/components/ResetJobV2ConfigForm.tsx new file mode 100644 index 0000000000..8aba0b9ca5 --- /dev/null +++ b/vite/src/views/admin/components/ResetJobV2ConfigForm.tsx @@ -0,0 +1,207 @@ +import { Badge, Button, DialogFooter, Separator, Switch } from "@autumn/ui"; +import { useStore } from "@tanstack/react-form"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useAppForm } from "@/hooks/form/form"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr } from "@/utils/genUtils"; +import { + RESET_JOB_V2_QUERY_KEY, + type ResetJobV2Config, + type ResetJobV2FormValues, +} from "./resetJobV2ConfigTypes"; + +export const ResetJobV2ConfigForm = ({ + config, + onClose, +}: { + config: ResetJobV2Config; + onClose: () => void; +}) => { + const axiosInstance = useAxiosInstance(); + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: async (nextConfig: ResetJobV2FormValues) => { + await axiosInstance.put("/admin/reset-job-v2-config", nextConfig); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: RESET_JOB_V2_QUERY_KEY, + }); + toast.success("Batch Reset V2 config saved"); + onClose(); + }, + onError: (error) => { + toast.error(getBackendErr(error, "Failed to save Batch Reset V2 config")); + }, + }); + const form = useAppForm({ + defaultValues: { + enabled: config.enabled, + scanBatchSize: config.scanBatchSize as number | null, + workerBatchSize: config.workerBatchSize as number | null, + maxConcurrentJobs: config.maxConcurrentJobs as number | null, + scanIntervalMs: config.scanIntervalMs as number | null, + queueHighWaterMessages: config.queueHighWaterMessages as number | null, + queueDepthPollMs: config.queueDepthPollMs as number | null, + }, + onSubmit: async ({ value }) => { + if ( + value.scanBatchSize === null || + value.workerBatchSize === null || + value.maxConcurrentJobs === null || + value.scanIntervalMs === null || + value.queueHighWaterMessages === null || + value.queueDepthPollMs === null + ) { + return; + } + + await mutation.mutateAsync({ + enabled: value.enabled, + scanBatchSize: value.scanBatchSize, + workerBatchSize: value.workerBatchSize, + maxConcurrentJobs: value.maxConcurrentJobs, + scanIntervalMs: value.scanIntervalMs, + queueHighWaterMessages: value.queueHighWaterMessages, + queueDepthPollMs: value.queueDepthPollMs, + }); + }, + }); + const isDirty = useStore(form.store, (state) => state.isDirty); + + return ( + <> +
+ + {(field) => ( +
+
+
+ Batch Reset V2 enabled +
+
+ When disabled, the cron stops scanning and enqueueing new + reset work. +
+
+ +
+ )} +
+ +
+ + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + +
+ +
+ +
+ + {config.configHealthy ? "Config healthy" : "Config unavailable"} + + {config.lastSuccessAt && ( + + Last refresh: {new Date(config.lastSuccessAt).toLocaleString()} + + )} +
+

+ {config.configConfigured === false + ? "S3 Batch Reset V2 config is not configured. The job defaults to disabled." + : config.error || + "Changes propagate to cron and worker instances within 10 seconds."} +

+
+
+ + + {mutation.error && ( + + {getBackendErr(mutation.error, "Failed to save config")} + + )} + + + + + ); +}; diff --git a/vite/src/views/admin/components/lazyBatchResetConfigTypes.ts b/vite/src/views/admin/components/lazyBatchResetConfigTypes.ts new file mode 100644 index 0000000000..244cd097d9 --- /dev/null +++ b/vite/src/views/admin/components/lazyBatchResetConfigTypes.ts @@ -0,0 +1,12 @@ +export type LazyBatchResetConfig = { + enabled: boolean; + configHealthy: boolean; + configConfigured: boolean; + lastSuccessAt: string | null; + error: string | null; +}; + +export const LAZY_BATCH_RESET_QUERY_KEY = [ + "admin-edge-config", + "lazy-batch-reset", +] as const; diff --git a/vite/src/views/admin/components/resetJobConfigTypes.ts b/vite/src/views/admin/components/resetJobConfigTypes.ts new file mode 100644 index 0000000000..aa22d98855 --- /dev/null +++ b/vite/src/views/admin/components/resetJobConfigTypes.ts @@ -0,0 +1,12 @@ +export type ResetJobConfig = { + enabled: boolean; + batchSize: number; + configHealthy: boolean; + configConfigured: boolean; + lastSuccessAt: string | null; + error: string | null; +}; + +export type ResetJobFormValues = Pick; + +export const RESET_JOB_QUERY_KEY = ["admin-edge-config", "reset-job"] as const; diff --git a/vite/src/views/admin/components/resetJobV2ConfigTypes.ts b/vite/src/views/admin/components/resetJobV2ConfigTypes.ts new file mode 100644 index 0000000000..40386633be --- /dev/null +++ b/vite/src/views/admin/components/resetJobV2ConfigTypes.ts @@ -0,0 +1,42 @@ +type NumericLimit = { + min: number; + max: number; +}; + +export type ResetJobV2Config = { + enabled: boolean; + scanBatchSize: number; + workerBatchSize: number; + maxConcurrentJobs: number; + scanIntervalMs: number; + queueHighWaterMessages: number; + queueDepthPollMs: number; + limits: { + scanBatchSize: NumericLimit; + workerBatchSize: NumericLimit; + maxConcurrentJobs: NumericLimit; + scanIntervalMs: NumericLimit; + queueHighWaterMessages: NumericLimit; + queueDepthPollMs: NumericLimit; + }; + configHealthy: boolean; + configConfigured: boolean; + lastSuccessAt: string | null; + error: string | null; +}; + +export type ResetJobV2FormValues = Pick< + ResetJobV2Config, + | "enabled" + | "scanBatchSize" + | "workerBatchSize" + | "maxConcurrentJobs" + | "scanIntervalMs" + | "queueHighWaterMessages" + | "queueDepthPollMs" +>; + +export const RESET_JOB_V2_QUERY_KEY = [ + "admin-edge-config", + "reset-job-v2", +] as const; diff --git a/vite/src/views/admin/edge-config/EdgeConfigView.tsx b/vite/src/views/admin/edge-config/EdgeConfigView.tsx index 319cc0d267..7406b3ef2f 100644 --- a/vite/src/views/admin/edge-config/EdgeConfigView.tsx +++ b/vite/src/views/admin/edge-config/EdgeConfigView.tsx @@ -436,7 +436,7 @@ export const EdgeConfigView = () => { if (isPending || isLoading) { return ( -
+
); @@ -494,7 +494,7 @@ export const EdgeConfigView = () => {

Rollout Edge Config

-

+

Manage global rollout percentages and per-org overrides.

diff --git a/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx b/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx index 433a9c9278..ec8cf7ec08 100644 --- a/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx +++ b/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx @@ -63,6 +63,7 @@ export const useCusProductQuery = () => { stableStates.entity_id, ]), queryFn: fetcher, + enabled: !!customer_id && !!product_id, }); useEffect(() => { diff --git a/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx b/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx index ba0c286a4d..b9f2b8d83a 100644 --- a/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx +++ b/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx @@ -9,6 +9,7 @@ import { toast } from "sonner"; import { AttachAdvancedSection, AttachFormProvider, + AttachLicenseLossWarning, AttachPlanOptions, AttachPlanSection, AttachProductSelection, @@ -171,6 +172,7 @@ function ReviewPreviewBlock() { transition={{ duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }} > + {error ? ( diff --git a/vite/src/views/customers2/components/sheets/BillingAutoTopupSheet.tsx b/vite/src/views/customers2/components/sheets/BillingAutoTopupSheet.tsx index 0104612669..d3c9aa2a6c 100644 --- a/vite/src/views/customers2/components/sheets/BillingAutoTopupSheet.tsx +++ b/vite/src/views/customers2/components/sheets/BillingAutoTopupSheet.tsx @@ -161,7 +161,7 @@ export function BillingAutoTopupSheet() { return null; } const parsedThreshold = Number.parseFloat(threshold); - if (Number.isNaN(parsedThreshold) || parsedThreshold < 0) { + if (Number.isNaN(parsedThreshold)) { toast.error("Please enter a valid threshold"); return null; } diff --git a/vite/src/views/migrations/migration/hooks/buildRunMigrationRequest.ts b/vite/src/views/migrations/migration/hooks/buildRunMigrationRequest.ts index da3e5b6190..630efdbd24 100644 --- a/vite/src/views/migrations/migration/hooks/buildRunMigrationRequest.ts +++ b/vite/src/views/migrations/migration/hooks/buildRunMigrationRequest.ts @@ -5,7 +5,6 @@ type BuildRunMigrationRequestParams = { dryRun: boolean; limit?: number; only?: string[]; - concurrency?: number; retryItemStatuses?: RetryableMigrationItemRunStatus[]; }; @@ -15,7 +14,6 @@ type RunMigrationRequest = { lazy_run: false; limit?: number; only?: string[]; - concurrency?: number; retry_item_statuses?: RetryableMigrationItemRunStatus[]; }; @@ -24,7 +22,6 @@ export const buildRunMigrationRequest = ({ dryRun, limit, only, - concurrency, retryItemStatuses, }: BuildRunMigrationRequestParams): RunMigrationRequest => { const request: RunMigrationRequest = { @@ -35,7 +32,6 @@ export const buildRunMigrationRequest = ({ if (limit !== undefined) request.limit = limit; if (only !== undefined) request.only = only; - if (concurrency !== undefined) request.concurrency = concurrency; if (retryItemStatuses && retryItemStatuses.length > 0) { request.retry_item_statuses = retryItemStatuses; diff --git a/vite/src/views/migrations/migration/hooks/useRealtimeSubscriptions.ts b/vite/src/views/migrations/migration/hooks/useRealtimeSubscriptions.ts index 85c7abcf38..ae2ed73931 100644 --- a/vite/src/views/migrations/migration/hooks/useRealtimeSubscriptions.ts +++ b/vite/src/views/migrations/migration/hooks/useRealtimeSubscriptions.ts @@ -40,13 +40,11 @@ export function useRealtimeSubscriptions({ dryRun, limit, only, - concurrency, retryItemStatuses, }: { dryRun: boolean; limit?: number; only?: string[]; - concurrency?: number; retryItemStatuses?: RetryableMigrationItemRunStatus[]; }) => { try { @@ -56,7 +54,6 @@ export function useRealtimeSubscriptions({ dryRun, limit, only, - concurrency, retryItemStatuses, }), ); diff --git a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx index 1fd6e25329..edca476dde 100644 --- a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx +++ b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx @@ -76,7 +76,6 @@ import { } from "@/utils/constants/customerListPagination"; import { useEnv } from "@/utils/envUtils"; import { pushPage } from "@/utils/genUtils"; -import { useAdmin } from "@/views/admin/hooks/useAdmin"; import { useCustomerFilters } from "@/views/customers/hooks/useCustomerFilters"; import { createCustomerListColumns } from "@/views/customers2/components/table/customer-list/CustomerListColumns"; import { CustomerListFilterButton } from "@/views/customers2/components/table/customer-list/CustomerListFilterButton"; @@ -101,31 +100,11 @@ import { import { RealtimeRunWatcher } from "./RealtimeRunWatcher"; import { useMigrationSheetStore } from "./useMigrationSheetStore"; -type AdminRunControls = { +type MigrationRunControlsState = { retryErrored: boolean; retrySkipped: boolean; - concurrency: string; }; -const MIN_CONCURRENCY = 1; -const MAX_CONCURRENCY = 5; -const ADMIN_MAX_CONCURRENCY = 100; - -function parseConcurrency({ - value, - maxConcurrency, -}: { - value: string; - maxConcurrency: number; -}): number | undefined { - const trimmed = value.trim(); - if (trimmed === "") return undefined; - const parsed = Number(trimmed); - if (!Number.isInteger(parsed)) return undefined; - if (parsed < MIN_CONCURRENCY || parsed > maxConcurrency) return undefined; - return parsed; -} - type CustomerRow = MigrationPreviewCustomer & { _event?: MigrationItemEvent; _activeStatus?: ActiveRunStatus; @@ -135,7 +114,7 @@ type CustomerRow = MigrationPreviewCustomer & { function buildRetryItemStatuses({ retryErrored, retrySkipped, -}: Pick) { +}: Pick) { const statuses: RetryableMigrationItemRunStatus[] = []; if (retryErrored) statuses.push("failed"); if (retrySkipped) statuses.push("skipped"); @@ -292,10 +271,9 @@ export function MigrationLiveView({ parseAsBoolean.withDefault(false), ); const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false); - const [runControls, setRunControls] = useState({ + const [runControls, setRunControls] = useState({ retryErrored: false, retrySkipped: false, - concurrency: String(MAX_CONCURRENCY), }); const [sample, setSample] = useState({ open: false, @@ -306,20 +284,9 @@ export function MigrationLiveView({ }); const { cancelRun, isCanceling } = useMigrationsQuery(); - const { isAdmin } = useAdmin(); - const maxConcurrency = isAdmin ? ADMIN_MAX_CONCURRENCY : MAX_CONCURRENCY; - const resolvedRunControls = { retryItemStatuses: buildRetryItemStatuses(runControls), - concurrency: parseConcurrency({ - value: runControls.concurrency, - maxConcurrency, - }), }; - const invalidConcurrency = - runControls.concurrency.trim() !== "" && - parseConcurrency({ value: runControls.concurrency, maxConcurrency }) === - undefined; const handleSearchChange = useCallback( (e: React.ChangeEvent) => { @@ -618,8 +585,6 @@ export function MigrationLiveView({ 0} hasSkippedItems={(progressCounts?.skipped ?? 0) > 0} /> @@ -633,7 +598,7 @@ export function MigrationLiveView({ triggerRun({ dryRun: false, ...resolvedRunControls }); }} isLoading={isRunning} - disabled={isRunInProgress || invalidConcurrency} + disabled={isRunInProgress} > Run @@ -729,8 +694,6 @@ export function MigrationLiveView({ 0} hasSkippedItems={(progressCounts?.skipped ?? 0) > 0} /> @@ -742,7 +705,6 @@ export function MigrationLiveView({ isLoading={sample.running === "dry"} disabled={ isRunInProgress || - invalidConcurrency || sample.running !== null || (sample.mode === "limit" ? !sample.limit || Number(sample.limit) < 1 @@ -782,7 +744,6 @@ export function MigrationLiveView({ isLoading={sample.running === "live"} disabled={ isRunInProgress || - invalidConcurrency || sample.running !== null || (sample.mode === "limit" ? !sample.limit || Number(sample.limit) < 1 @@ -942,48 +903,19 @@ function ExecutionProgressBadge({ function MigrationRunControls({ value, onChange, - invalidConcurrency = false, - maxConcurrency = MAX_CONCURRENCY, hasFailedItems = false, hasSkippedItems = false, }: { - value: AdminRunControls; - onChange: (value: AdminRunControls) => void; - invalidConcurrency?: boolean; - maxConcurrency?: number; + value: MigrationRunControlsState; + onChange: (value: MigrationRunControlsState) => void; hasFailedItems?: boolean; hasSkippedItems?: boolean; }) { + if (!hasFailedItems && !hasSkippedItems) return null; + return (
-
-
- - Concurrency - - - Customers processed in parallel. Max {maxConcurrency}. - -
- onChange({ ...value, concurrency: e.target.value })} - placeholder="Auto" - className={cn( - "w-20 text-sm", - invalidConcurrency && "border-red-500 focus-visible:ring-red-500", - )} - /> -
- {invalidConcurrency && ( - - Concurrency must be between {MIN_CONCURRENCY} and {maxConcurrency}. - - )} {hasFailedItems && (
diff --git a/vite/src/views/products/plan/components/edit-plan-details/usePlanBillingControlForm.ts b/vite/src/views/products/plan/components/edit-plan-details/usePlanBillingControlForm.ts index 3294ba4616..6f963c9f3f 100644 --- a/vite/src/views/products/plan/components/edit-plan-details/usePlanBillingControlForm.ts +++ b/vite/src/views/products/plan/components/edit-plan-details/usePlanBillingControlForm.ts @@ -51,7 +51,7 @@ const requireNumber = (min: number, message: string) => const AutoTopupFormSchema = z .object({ feature_id: z.string().min(1, "Please select a feature"), - threshold: requireNumber(0, "Please enter a valid threshold"), + threshold: z.number({ message: "Please enter a valid threshold" }), quantity: requireNumber(1, "Please enter a valid quantity"), has_purchase_limit: z.boolean(), purchase_limit_limit: z.number().nullable(), diff --git a/vite/src/views/products/plan/components/edit-plan-feature/AdvancedSettings.tsx b/vite/src/views/products/plan/components/edit-plan-feature/AdvancedSettings.tsx index 4e6f2c46e7..de1fa3c9c7 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/AdvancedSettings.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/AdvancedSettings.tsx @@ -7,6 +7,7 @@ import { UsageModel, } from "@autumn/shared"; import { SheetAccordion, SheetAccordionItem } from "@autumn/ui"; +import { useProduct } from "@/components/v2/inline-custom-plan-editor/PlanEditorContext"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { getFeatureCreditSystem, @@ -23,6 +24,7 @@ import { UsageLimit } from "./advanced-settings/UsageLimit"; export function AdvancedSettings() { const { features } = useFeaturesQuery(); const { item } = useProductItemContext(); + const { product } = useProduct(); if (!item) return null; @@ -32,9 +34,12 @@ export function AdvancedSettings() { const showUsageLimits = isPriced; const showRollover = hasCreditSystem || usageType === FeatureUsageType.Single; - // Deprecated in favor of licenses. Only surface it for items that already - // have it set, so existing plans keep working. - const showEntityFeature = item.entity_feature_id != null; + // Deprecated in favor of licenses. Surface it whenever any item in the plan + // uses an entity feature, so all items in such plans keep working. + const showEntityFeature = + item.entity_feature_id != null || + (product?.items?.some((planItem) => planItem?.entity_feature_id != null) ?? + false); // Proration shows for prepaid or continuous use features (not consumable + pay-per-use) const showProration = isPriced && diff --git a/vite/src/views/products/plan/versioning/PropagationTargetsStep.tsx b/vite/src/views/products/plan/versioning/PropagationTargetsStep.tsx index c8fba7528c..33e848a8ae 100644 --- a/vite/src/views/products/plan/versioning/PropagationTargetsStep.tsx +++ b/vite/src/views/products/plan/versioning/PropagationTargetsStep.tsx @@ -51,7 +51,7 @@ export function PropagationTargetsStep({ return (