feat(billing): implement pooled balance attach flow - #2341
Conversation
Compute and execute pooled balance contributions during attach, expose synthetic pools through customer responses and the dashboard, and cover free, lifetime, and paid monthly lifecycles.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
|
Automatic Review Skipped Too many files for automatic review. If you would still like a review, you can trigger one manually by commenting: |
| for (const fullCustomerEntitlement of pooledBalancePlan.updatePoolBalances) { | ||
| const pooledBalance = fullCustomerEntitlement.pooled_balance; | ||
| if (!pooledBalance) { | ||
| throw new InternalError({ | ||
| message: `Synthetic customer entitlement '${fullCustomerEntitlement.id}' is missing its pooled balance.`, | ||
| }); | ||
| } | ||
|
|
||
| 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, | ||
| cache_version: sql`${customerEntitlements.cache_version} + 1`, | ||
| }) | ||
| .where(eq(customerEntitlements.id, fullCustomerEntitlement.id)); | ||
|
|
||
| await tx | ||
| .update(pooledBalances) | ||
| .set({ | ||
| granted: pooledBalance.granted, | ||
| 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)); | ||
| } |
There was a problem hiding this comment.
Concurrent Updates Lose Grants
Two attaches can compute absolute balance and granted values from the same customer snapshot. The second transaction then overwrites the first transaction's update while both contributions remain, so the persisted grant no longer equals the contribution total.
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/internal/billing/v2/pooledBalances/execute/executePooledBalancePlan.ts
Line: 55-88
Comment:
**Concurrent Updates Lose Grants**
Two attaches can compute absolute `balance` and `granted` values from the same customer snapshot. The second transaction then overwrites the first transaction's update while both contributions remain, so the persisted grant no longer equals the contribution total.
How can I resolve this? If you propose a fix, please make it concise.| newCusProducts: insertCustomerProducts, | ||
| }); | ||
|
|
||
| await executePooledBalancePlan({ | ||
| ctx, | ||
| pooledBalancePlan: autumnBillingPlan.pooledBalancePlan, | ||
| }); |
There was a problem hiding this comment.
Pooled Failure Cannot Roll Back Attach
The source product is inserted before executePooledBalancePlan opens its separate transaction. If a pooled insert or update fails, only pooled writes roll back while the product, preceding entitlements, and completed Stripe operation remain, exposing an active product with no matching pooled grant.
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts
Line: 114-120
Comment:
**Pooled Failure Cannot Roll Back Attach**
The source product is inserted before `executePooledBalancePlan` opens its separate transaction. If a pooled insert or update fails, only pooled writes roll back while the product, preceding entitlements, and completed Stripe operation remain, exposing an active product with no matching pooled grant.
How can I resolve this? If you propose a fix, please make it concise.| addToInsertPoolContributions({ | ||
| pooledBalancePlan: computeContext.plan, | ||
| contribution, | ||
| }); |
There was a problem hiding this comment.
Existing Contributions Are Reinserted
Every incoming source becomes an insert even when its source_customer_entitlement_id already owns a contribution and was not removed as outgoing. An idempotent attach or retry then violates the contribution uniqueness constraint instead of updating or retaining the existing row.
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/applyIncomingPooledBalanceSources.ts
Line: 83-86
Comment:
**Existing Contributions Are Reinserted**
Every incoming source becomes an insert even when its `source_customer_entitlement_id` already owns a contribution and was not removed as outgoing. An idempotent attach or retry then violates the contribution uniqueness constraint instead of updating or retaining the existing row.
How can I resolve this? If you propose a fix, please make it concise.| if (resetMode === PooledBalanceResetMode.Subscription) { | ||
| return { | ||
| stripeSubscriptionId: | ||
| stripeSubscriptionId ?? | ||
| customerProduct.subscription_ids?.[0] ?? | ||
| customerProduct.id, | ||
| customerLicenseLinkId: null, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Product ID Becomes Subscription Owner
When a paid recurring attach has no supplied or existing subscription ID, this fallback stores the customer-product ID as stripe_subscription_id. The database accepts the non-null value, but renewal and ownership logic later treats it as a real Stripe subscription, so the pool cannot be reconciled against its actual billing lifecycle.
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/computePooledBalanceLifecycle.ts
Line: 103-111
Comment:
**Product ID Becomes Subscription Owner**
When a paid recurring attach has no supplied or existing subscription ID, this fallback stores the customer-product ID as `stripe_subscription_id`. The database accepts the non-null value, but renewal and ownership logic later treats it as a real Stripe subscription, so the pool cannot be reconciled against its actual billing lifecycle.
How can I resolve this? If you propose a fix, please make it concise.| }, | ||
| }, | ||
| orderBy: desc(pooledBalances.created_at), | ||
| limit: POOLED_CUSTOMER_ENTITLEMENT_LIMIT, |
There was a problem hiding this comment.
A customer with more than 100 pool identities receives a silently truncated pool graph. Omitted pools disappear from hydrated responses and attach computation can treat an existing identity as absent, leading to an incorrect insert plan or a uniqueness failure.
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/internal/customers/pooledBalances/repos/listPooledCustomerEntitlements.ts
Line: 30
Comment:
**Pool Graph Stops At 100**
A customer with more than 100 pool identities receives a silently truncated pool graph. Omitted pools disappear from hydrated responses and attach computation can treat an existing identity as absent, leading to an incorrect insert plan or a uniqueness failure.
How can I resolve this? If you propose a fix, please make it concise.| : eq(customerProducts.internal_entity_id, internalEntityId), | ||
| ), | ||
| ) | ||
| .orderBy(desc(pooledBalanceContributions.created_at)) |
There was a problem hiding this comment.
Contribution Graph Stops At 100
An entity or customer with more than 100 source contributions is hydrated with an incomplete ownership graph. Omitted source entitlements lack pooled_balance_contribution, so later replacement logic can fail to remove their grants and leave the shared balance overstated.
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/internal/customers/pooledBalances/repos/listScopedPooledBalanceContributions.ts
Line: 49
Comment:
**Contribution Graph Stops At 100**
An entity or customer with more than 100 source contributions is hydrated with an incomplete ownership graph. Omitted source entitlements lack `pooled_balance_contribution`, so later replacement logic can fail to remove their grants and leave the shared balance overstated.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
11 issues found across 76 files
Confidence score: 2/5
- The highest-risk issue is in
server/src/internal/billing/v2/pooledBalances/execute/executePooledBalancePlan.ts: concurrent attaches can both read the same starting balance and the later absolute write can overwrite the earlier contribution, causing lost pooled funds/accounting drift after merge. Serialize or atomically increment pooled balance updates before merging. server/src/internal/billing/v2/execute/executeAutumnBillingPlan.tsappears non-atomic across steps (source products can commit before pooled-balance work, and cache can stay stale), so failures can leave partial state and subsequent checks using pre-attach amounts. Move these mutations into one transactional path and invalidate/update entitlement cache in the same flow before merge.- Pooled/non-pooled separation is still inconsistent in
server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments.tsandserver/src/internal/customers/repos/getFullSubject/getEntityOptionsAggregateFragments.ts, so pooled contributions can leak into entity/option prepaid breakdowns and counts. Apply the pooled exclusion uniformly across all CTE match paths before merging. - Hard row caps in
server/src/internal/customers/pooledBalances/repos/listScopedPooledBalanceContributions.ts,server/src/internal/customers/pooledBalances/hydrateFullCustomerPooledBalances.ts, andserver/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.tscan omit older pooled records, leading to failed detach/replace behavior and missing loose entitlements for high-history customers. Remove or redesign these fixed limits (or page deterministically) and add a high-volume test case before merging.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments.ts">
<violation number="1" location="server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments.ts:210">
P1: Entity breakdowns can still expose and count pooled contributions when a pooled and non-pooled entitlement share a feature. Filter pooled entitlements in the balance/rollover CTEs as well, so their maps use the same population as `entity_aggregate_totals`.</violation>
</file>
<file name="shared/utils/common/mathUtils.ts">
<violation number="1" location="shared/utils/common/mathUtils.ts:4">
P3: Pooled-balance arithmetic is unchanged because neither new helper is called anywhere. Wire these helpers into the attach/contribution calculation (and add coverage), or omit the unused module from this PR.</violation>
</file>
<file name="server/src/internal/customers/repos/getFullSubject/getEntityOptionsAggregateFragments.ts">
<violation number="1" location="server/src/internal/customers/repos/getFullSubject/getEntityOptionsAggregateFragments.ts:58">
P1: Pooled entitlements matched through `option_feature_id` still enter `entity_option_prepaid_rows`, so their option grants can be included despite this exclusion. Group both match alternatives beneath `ent.pooled IS NOT TRUE`.</violation>
</file>
<file name="server/src/internal/billing/v2/pooledBalances/execute/executePooledBalancePlan.ts">
<violation number="1" location="server/src/internal/billing/v2/pooledBalances/execute/executePooledBalancePlan.ts:66">
P1: Concurrent attaches to the same pooled balance can lose a contribution: both plans snapshot the old balance, then this absolute `balance` write lets the later transaction overwrite the earlier result. Serialize access to the pooled row during planning/execution or persist contribution deltas atomically rather than replacing the computed total.</violation>
</file>
<file name="server/src/internal/customers/pooledBalances/repos/listScopedPooledBalanceContributions.ts">
<violation number="1" location="server/src/internal/customers/pooledBalances/repos/listScopedPooledBalanceContributions.ts:9">
P1: Replacing an entity product after it has more than 100 pooled source entitlements can leave the outgoing contribution in its pool. This fixed limit silently omits older rows, so removal sees no `pooled_balance_contribution`; paginate or fetch all contributions required for the loaded scope.</violation>
</file>
<file name="server/src/internal/customers/pooledBalances/hydrateFullCustomerPooledBalances.ts">
<violation number="1" location="server/src/internal/customers/pooledBalances/hydrateFullCustomerPooledBalances.ts:16">
P1: Customers with more than 100 pooled balances cannot safely attach or replace a pooled source: older pools are omitted from this hydration, so compute either cannot find the source pool or treats it as new. Hydrate the complete pool set for billing reads (or paginate it) rather than using the display-oriented 100-row cap.</violation>
</file>
<file name="server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts">
<violation number="1" location="server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts:117">
P1: Existing pooled balances can remain stale in the entitlement cache after an attach, so subsequent balance checks may use the pre-attach amount. Route pool entitlement mutations through the DB-and-cache actions, or invalidate/update their cache entries after this executor completes.</violation>
<violation number="2" location="server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts:117">
P1: `executePooledBalancePlan` opens its own transaction, but the preceding `insertNewCusProducts` (which inserts source products and their normalized entitlements) has already committed. If the pooled-balance transaction fails, the source product and its entitlements remain in the database with no matching pool or contribution rows—leaving an active product whose normalized-zero balance is never backed by a shared grant. Consider either wrapping both operations in a single transaction or compensating on failure.</violation>
</file>
<file name="shared/models/pooledBalanceModels/pooledBalanceTable.ts">
<violation number="1" location="shared/models/pooledBalanceModels/pooledBalanceTable.ts:165">
P3: Writes to pooled balance contributions will maintain two identical indexes on `source_customer_entitlement_id`, adding storage and write overhead without improving queries. The unique constraint already supplies this index; removing the explicit index avoids duplicate maintenance.</violation>
</file>
<file name="server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts">
<violation number="1" location="server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts:264">
P2: Customers with many historical pooled balances can lose current loose entitlements from this response because all synthetic pools now share the 200-row extra-entitlement cap. Consider aggregating/capping pooled balances separately so they cannot crowd out active non-pooled extras.</violation>
</file>
<file name="server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/normalizePooledBalanceContributionCustomerEntitlement.ts">
<violation number="1" location="server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/normalizePooledBalanceContributionCustomerEntitlement.ts:3">
P2: This function mutates the input `contributionCustomerEntitlement` object in place, which is inconsistent with sibling functions in this directory that return new objects without side effects. In-place mutation makes the control flow harder to trace: the caller iterates over `customerProduct.customer_entitlements` and silently zeroes out entries via reference, which can cause subtle bugs if the array is used again after the loop. Prefer returning a new normalized object to match the existing stateless pattern.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| BOOL_OR(ce.usage_allowed) AS usage_allowed | ||
| FROM entity_level_cus_ents ce | ||
| JOIN entitlements ent ON ce.entitlement_id = ent.id | ||
| WHERE ent.pooled IS NOT TRUE |
There was a problem hiding this comment.
P1: Entity breakdowns can still expose and count pooled contributions when a pooled and non-pooled entitlement share a feature. Filter pooled entitlements in the balance/rollover CTEs as well, so their maps use the same population as entity_aggregate_totals.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments.ts, line 210:
<comment>Entity breakdowns can still expose and count pooled contributions when a pooled and non-pooled entitlement share a feature. Filter pooled entitlements in the balance/rollover CTEs as well, so their maps use the same population as `entity_aggregate_totals`.</comment>
<file context>
@@ -207,6 +207,7 @@ export const getEntityAggregateFragments = ({
BOOL_OR(ce.usage_allowed) AS usage_allowed
FROM entity_level_cus_ents ce
JOIN entitlements ent ON ce.entitlement_id = ent.id
+ WHERE ent.pooled IS NOT TRUE
GROUP BY ce.internal_feature_id, ce.internal_customer_id
),
</file context>
| ) prepaid_price ON true | ||
| WHERE | ||
| ( | ||
| WHERE ent.pooled IS NOT TRUE |
There was a problem hiding this comment.
P1: Pooled entitlements matched through option_feature_id still enter entity_option_prepaid_rows, so their option grants can be included despite this exclusion. Group both match alternatives beneath ent.pooled IS NOT TRUE.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/customers/repos/getFullSubject/getEntityOptionsAggregateFragments.ts, line 58:
<comment>Pooled entitlements matched through `option_feature_id` still enter `entity_option_prepaid_rows`, so their option grants can be included despite this exclusion. Group both match alternatives beneath `ent.pooled IS NOT TRUE`.</comment>
<file context>
@@ -55,8 +55,8 @@ export const getEntityOptionsAggregateFragments = () => {
) prepaid_price ON true
- WHERE
- (
+ WHERE ent.pooled IS NOT TRUE
+ AND (
eor.option_internal_feature_id IS NOT NULL
</file context>
| await tx | ||
| .update(customerEntitlements) | ||
| .set({ | ||
| balance: fullCustomerEntitlement.balance ?? 0, |
There was a problem hiding this comment.
P1: Concurrent attaches to the same pooled balance can lose a contribution: both plans snapshot the old balance, then this absolute balance write lets the later transaction overwrite the earlier result. Serialize access to the pooled row during planning/execution or persist contribution deltas atomically rather than replacing the computed total.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/pooledBalances/execute/executePooledBalancePlan.ts, line 66:
<comment>Concurrent attaches to the same pooled balance can lose a contribution: both plans snapshot the old balance, then this absolute `balance` write lets the later transaction overwrite the earlier result. Serialize access to the pooled row during planning/execution or persist contribution deltas atomically rather than replacing the computed total.</comment>
<file context>
@@ -0,0 +1,118 @@
+ await tx
+ .update(customerEntitlements)
+ .set({
+ balance: fullCustomerEntitlement.balance ?? 0,
+ adjustment: fullCustomerEntitlement.adjustment ?? 0,
+ additional_balance: fullCustomerEntitlement.additional_balance ?? 0,
</file context>
| import { and, desc, eq, getTableColumns, isNull } from "drizzle-orm"; | ||
| import type { DrizzleCli } from "@/db/initDrizzle"; | ||
|
|
||
| const POOLED_BALANCE_CONTRIBUTION_LIMIT = 100; |
There was a problem hiding this comment.
P1: Replacing an entity product after it has more than 100 pooled source entitlements can leave the outgoing contribution in its pool. This fixed limit silently omits older rows, so removal sees no pooled_balance_contribution; paginate or fetch all contributions required for the loaded scope.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/customers/pooledBalances/repos/listScopedPooledBalanceContributions.ts, line 9:
<comment>Replacing an entity product after it has more than 100 pooled source entitlements can leave the outgoing contribution in its pool. This fixed limit silently omits older rows, so removal sees no `pooled_balance_contribution`; paginate or fetch all contributions required for the loaded scope.</comment>
<file context>
@@ -0,0 +1,51 @@
+import { and, desc, eq, getTableColumns, isNull } from "drizzle-orm";
+import type { DrizzleCli } from "@/db/initDrizzle";
+
+const POOLED_BALANCE_CONTRIBUTION_LIMIT = 100;
+
+export const listScopedPooledBalanceContributions = async ({
</file context>
| internalEntityId: string | null | undefined; | ||
| }): Promise<void> => { | ||
| const [pooledCustomerEntitlements, contributions] = await Promise.all([ | ||
| pooledBalancesRepo.listPooledCustomerEntitlements({ |
There was a problem hiding this comment.
P1: Customers with more than 100 pooled balances cannot safely attach or replace a pooled source: older pools are omitted from this hydration, so compute either cannot find the source pool or treats it as new. Hydrate the complete pool set for billing reads (or paginate it) rather than using the display-oriented 100-row cap.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/customers/pooledBalances/hydrateFullCustomerPooledBalances.ts, line 16:
<comment>Customers with more than 100 pooled balances cannot safely attach or replace a pooled source: older pools are omitted from this hydration, so compute either cannot find the source pool or treats it as new. Hydrate the complete pool set for billing reads (or paginate it) rather than using the display-oriented 100-row cap.</comment>
<file context>
@@ -0,0 +1,42 @@
+ internalEntityId: string | null | undefined;
+}): Promise<void> => {
+ const [pooledCustomerEntitlements, contributions] = await Promise.all([
+ pooledBalancesRepo.listPooledCustomerEntitlements({
+ db: ctx.db,
+ internalCustomerId: fullCustomer.internal_id,
</file context>
| newCusProducts: insertCustomerProducts, | ||
| }); | ||
|
|
||
| await executePooledBalancePlan({ |
There was a problem hiding this comment.
P1: executePooledBalancePlan opens its own transaction, but the preceding insertNewCusProducts (which inserts source products and their normalized entitlements) has already committed. If the pooled-balance transaction fails, the source product and its entitlements remain in the database with no matching pool or contribution rows—leaving an active product whose normalized-zero balance is never backed by a shared grant. Consider either wrapping both operations in a single transaction or compensating on failure.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts, line 117:
<comment>`executePooledBalancePlan` opens its own transaction, but the preceding `insertNewCusProducts` (which inserts source products and their normalized entitlements) has already committed. If the pooled-balance transaction fails, the source product and its entitlements remain in the database with no matching pool or contribution rows—leaving an active product whose normalized-zero balance is never backed by a shared grant. Consider either wrapping both operations in a single transaction or compensating on failure.</comment>
<file context>
@@ -113,6 +114,11 @@ export const executeAutumnBillingPlan = async ({
newCusProducts: insertCustomerProducts,
});
+ await executePooledBalancePlan({
+ ctx,
+ pooledBalancePlan: autumnBillingPlan.pooledBalancePlan,
</file context>
| JOIN features f ON f.internal_id = e.internal_feature_id | ||
| WHERE e.id = ce.entitlement_id | ||
| AND f.type = 'boolean' | ||
| ce.is_pooled_balance IS TRUE |
There was a problem hiding this comment.
P2: Customers with many historical pooled balances can lose current loose entitlements from this response because all synthetic pools now share the 200-row extra-entitlement cap. Consider aggregating/capping pooled balances separately so they cannot crowd out active non-pooled extras.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts, line 264:
<comment>Customers with many historical pooled balances can lose current loose entitlements from this response because all synthetic pools now share the 200-row extra-entitlement cap. Consider aggregating/capping pooled balances separately so they cannot crowd out active non-pooled extras.</comment>
<file context>
@@ -250,20 +250,31 @@ export const getFullSubjectRowsQuery = ({
- JOIN features f ON f.internal_id = e.internal_feature_id
- WHERE e.id = ce.entitlement_id
- AND f.type = 'boolean'
+ ce.is_pooled_balance IS TRUE
+ OR (
+ (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
</file context>
| @@ -0,0 +1,12 @@ | |||
| import type { FullCustomerEntitlement } from "@autumn/shared"; | |||
|
|
|||
| export const normalizePooledBalanceContributionCustomerEntitlement = ({ | |||
There was a problem hiding this comment.
P2: This function mutates the input contributionCustomerEntitlement object in place, which is inconsistent with sibling functions in this directory that return new objects without side effects. In-place mutation makes the control flow harder to trace: the caller iterates over customerProduct.customer_entitlements and silently zeroes out entries via reference, which can cause subtle bugs if the array is used again after the loop. Prefer returning a new normalized object to match the existing stateless pattern.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/normalizePooledBalanceContributionCustomerEntitlement.ts, line 3:
<comment>This function mutates the input `contributionCustomerEntitlement` object in place, which is inconsistent with sibling functions in this directory that return new objects without side effects. In-place mutation makes the control flow harder to trace: the caller iterates over `customerProduct.customer_entitlements` and silently zeroes out entries via reference, which can cause subtle bugs if the array is used again after the loop. Prefer returning a new normalized object to match the existing stateless pattern.</comment>
<file context>
@@ -0,0 +1,12 @@
+import type { FullCustomerEntitlement } from "@autumn/shared";
+
+export const normalizePooledBalanceContributionCustomerEntitlement = ({
+ contributionCustomerEntitlement,
+}: {
</file context>
| import { Decimal } from "decimal.js"; | ||
|
|
||
| /** Adds decimal values without floating-point drift. Nullish values are zero. */ | ||
| export const addSafe = ({ |
There was a problem hiding this comment.
P3: Pooled-balance arithmetic is unchanged because neither new helper is called anywhere. Wire these helpers into the attach/contribution calculation (and add coverage), or omit the unused module from this PR.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At shared/utils/common/mathUtils.ts, line 4:
<comment>Pooled-balance arithmetic is unchanged because neither new helper is called anywhere. Wire these helpers into the attach/contribution calculation (and add coverage), or omit the unused module from this PR.</comment>
<file context>
@@ -0,0 +1,19 @@
+import { Decimal } from "decimal.js";
+
+/** Adds decimal values without floating-point drift. Nullish values are zero. */
+export const addSafe = ({
+ left,
+ right,
</file context>
| .concurrently(), | ||
| index("idx_pooled_balance_contributions_reset_owner") | ||
| .on(table.reset_owner_type, table.reset_owner_id, table.pooled_balance_id) | ||
| index("idx_pooled_balance_contributions_source_customer_entitlement") |
There was a problem hiding this comment.
P3: Writes to pooled balance contributions will maintain two identical indexes on source_customer_entitlement_id, adding storage and write overhead without improving queries. The unique constraint already supplies this index; removing the explicit index avoids duplicate maintenance.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At shared/models/pooledBalanceModels/pooledBalanceTable.ts, line 165:
<comment>Writes to pooled balance contributions will maintain two identical indexes on `source_customer_entitlement_id`, adding storage and write overhead without improving queries. The unique constraint already supplies this index; removing the explicit index avoids duplicate maintenance.</comment>
<file context>
@@ -136,22 +156,14 @@ export const pooledBalanceContributions = pgTable(
- .concurrently(),
- index("idx_pooled_balance_contributions_reset_owner")
- .on(table.reset_owner_type, table.reset_owner_id, table.pooled_balance_id)
+ index("idx_pooled_balance_contributions_source_customer_entitlement")
+ .on(table.source_customer_entitlement_id)
.concurrently(),
</file context>
Summary
Validation
Summary by cubic
Implements the pooled balance attach flow: creates pools, computes contributions, and persists them during attach. Surfaces synthetic pooled balances in APIs and the dashboard while hiding contribution sources, covering free monthly, lifetime, and paid monthly lifecycles with Stripe-linked resets where needed.
New Features
computeAttachPooledBalancePlan→ merged intoAutumnBillingPlan→executePooledBalancePlan.CusServiceand subject queries; excludes pooled sources from resets/aggregates; propagates Stripe subscription IDs whenPooledBalanceResetMode.Subscription.customer_entitlements.is_pooled_balance,pooled_balances.granted, contributions keyed bysource_customer_entitlement_id, and cascade FKs.Migration
0047_next_avengers.Written for commit f288259. Summary will update on new commits.
Greptile Summary
This PR adds pooled balances to the entity-product attach flow. The main changes are:
Confidence Score: 2/5
Concurrent attaches can lose grants, and pooled failures can leave a paid product partially committed.
Pooled execution, lifecycle computation, and pooled repository loaders.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant A as Attach request participant S as Stripe participant E as Autumn executor participant P as Pooled transaction participant DB as PostgreSQL A->>S: Apply subscription changes S-->>A: Success A->>E: Execute Autumn plan E->>DB: Commit product and entitlement writes E->>P: Execute pooled plan P->>DB: Insert or update pool and contributions alt pooled write succeeds DB-->>P: Commit P-->>E: Success else conflict or stale update DB-->>P: Error or overwritten balance P-->>E: Roll back pooled writes Note over E,DB: Earlier product and Stripe state remains committed end%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant A as Attach request participant S as Stripe participant E as Autumn executor participant P as Pooled transaction participant DB as PostgreSQL A->>S: Apply subscription changes S-->>A: Success A->>E: Execute Autumn plan E->>DB: Commit product and entitlement writes E->>P: Execute pooled plan P->>DB: Insert or update pool and contributions alt pooled write succeeds DB-->>P: Commit P-->>E: Success else conflict or stale update DB-->>P: Error or overwritten balance P-->>E: Roll back pooled writes Note over E,DB: Earlier product and Stripe state remains committed endPrompt To Fix All With AI
Reviews (1): Last reviewed commit: "feat(billing): 🎸 implement pooled balan..." | Re-trigger Greptile
Context used: