Skip to content

feat(billing): implement pooled balance attach flow - #2341

Open
johnyeocx wants to merge 1 commit into
feat/pooled-balances-v2from
feat/pooled-balances-billing-rework
Open

feat(billing): implement pooled balance attach flow#2341
johnyeocx wants to merge 1 commit into
feat/pooled-balances-v2from
feat/pooled-balances-billing-rework

Conversation

@johnyeocx

@johnyeocx johnyeocx commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • compute and execute pooled balance contributions during attach
  • hydrate synthetic pooled balances through customer and full-subject responses
  • expose pooled balances in the dashboard while hiding contribution entitlements
  • cover free monthly, lifetime, paid monthly, and outgoing-source cases

Validation

  • Knip
  • server TypeScript build
  • Vite TypeScript build

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

    • Adds pooled balance planning to attach: computeAttachPooledBalancePlan → merged into AutumnBillingPlanexecutePooledBalancePlan.
    • Hydrates synthetic pools in CusService and subject queries; excludes pooled sources from resets/aggregates; propagates Stripe subscription IDs when PooledBalanceResetMode.Subscription.
    • Schema updates: customer_entitlements.is_pooled_balance, pooled_balances.granted, contributions keyed by source_customer_entitlement_id, and cascade FKs.
    • Dashboard: shows a “Pooled” row and hides contribution entitlements; filters out pooled sources in feature usage.
    • Test coverage: integration tests for attach and pooled lifecycles; UI unit tests for feature usage filtering.
  • Migration

    • Run migration 0047_next_avengers.
    • No backfill required (new fields have safe defaults); deploy server and dashboard together.

Written for commit f288259. Summary will update on new commits.

Review in cubic

Greptile Summary

This PR adds pooled balances to the entity-product attach flow. The main changes are:

  • Improvements: Computes and persists pooled grants and source contributions.
  • API changes: Hydrates synthetic pools into customer and subject responses.
  • Improvements: Shows pooled balances in the dashboard while hiding source entitlements.
  • API changes: Adds pooled-balance tables, relations, plan models, and shared exports.
  • Bug fixes: Covers free monthly, lifetime, paid monthly, usage, and replacement flows.

Confidence Score: 2/5

Concurrent attaches can lose grants, and pooled failures can leave a paid product partially committed.

  • Absolute pool updates can overwrite another attach.
  • Pool creation and contribution insertion are not conflict-safe.
  • The pooled transaction does not include preceding product writes.
  • Repository limits can produce incomplete pool and contribution graphs.

Pooled execution, lifecycle computation, and pooled repository loaders.

Important Files Changed

Filename Overview
server/src/internal/billing/v2/pooledBalances/execute/executePooledBalancePlan.ts Adds transactional pooled persistence, but absolute snapshot updates race and the transaction excludes surrounding attach writes.
server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts Runs pooled persistence after source-product insertion without a shared transaction.
server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/applyIncomingPooledBalanceSources.ts Computes incoming contributions and normalizes sources, but always plans contribution inserts.
server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/computePooledBalanceLifecycle.ts Computes pool lifecycle ownership but can use a customer-product ID as a subscription owner.
server/src/internal/customers/pooledBalances/repos/listPooledCustomerEntitlements.ts Loads synthetic pooled entitlements but silently truncates the pool graph.
server/src/internal/customers/pooledBalances/repos/listScopedPooledBalanceContributions.ts Loads scoped contributions but silently truncates the ownership graph.
shared/models/pooledBalanceModels/pooledBalanceTable.ts Adds lifecycle and identity constraints that expose unhandled concurrent and repeated insert paths.

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
Loading
%%{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
    end
Loading
Prompt To Fix All With AI
Fix the following 6 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 6
server/src/internal/billing/v2/pooledBalances/execute/executePooledBalancePlan.ts:55-88
**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.

### Issue 2 of 6
server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts:114-120
**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.

### Issue 3 of 6
server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/applyIncomingPooledBalanceSources.ts:83-86
**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.

### Issue 4 of 6
server/src/internal/billing/v2/pooledBalances/compute/applyIncomingPooledBalanceSources/computePooledBalanceLifecycle.ts:103-111
**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.

### Issue 5 of 6
server/src/internal/customers/pooledBalances/repos/listPooledCustomerEntitlements.ts:30
**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.

### Issue 6 of 6
server/src/internal/customers/pooledBalances/repos/listScopedPooledBalanceContributions.ts:49
**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.

Reviews (1): Last reviewed commit: "feat(billing): 🎸 implement pooled balan..." | Re-trigger Greptile

Greptile also left 6 inline comments on this PR.

Context used:

  • Context used - When generating the key changes section of the sum... (source)

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.
@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
checkout Ignored Ignored Jul 21, 2026 9:16pm
landing-page Ignored Ignored Jul 21, 2026 9:16pm

Request Review

@johnyeocx
johnyeocx requested a review from ay-rod as a code owner July 21, 2026 21:16
@entelligence-ai-pr-reviews

Copy link
Copy Markdown
Contributor

Automatic Review Skipped

Too many files for automatic review.

If you would still like a review, you can trigger one manually by commenting:

@entelligence review

Comment on lines +55 to +88
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));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines 114 to +120
newCusProducts: insertCustomerProducts,
});

await executePooledBalancePlan({
ctx,
pooledBalancePlan: autumnBillingPlan.pooledBalancePlan,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +83 to +86
addToInsertPoolContributions({
pooledBalancePlan: computeContext.plan,
contribution,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +103 to +111
if (resetMode === PooledBalanceResetMode.Subscription) {
return {
stripeSubscriptionId:
stripeSubscriptionId ??
customerProduct.subscription_ids?.[0] ??
customerProduct.id,
customerLicenseLinkId: null,
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts appears 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.ts and server/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, and server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts can 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant