Skip to content

License pooled balances: lifecycle, batch transitions, and runtime - #3162

Open
johnyeocx wants to merge 4 commits into
devfrom
cursor/9165cadb
Open

License pooled balances: lifecycle, batch transitions, and runtime#3162
johnyeocx wants to merge 4 commits into
devfrom
cursor/9165cadb

Conversation

@johnyeocx

@johnyeocx johnyeocx commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • License seats contribute to pooled balances keyed by customer_license_link_id; batchTransition handles amount-change, add/remove, and pooledness/identity flips in set-based SQL.
  • Runtime treats those pools like any other: check/track read the pool, lazy reset and rollover size off the pool grant, and cron selects the synthetic row (never seat sources).
  • A dead parent hides the pool from reset hydration and the V1 cron scan. Pooled migration customizes stay on the per_customer lane.

Test plan

  • license-pooled-* under tests/integration/licenses/pooled-balances (identity, add/remove, amount-change, flips, reset, check/track, parent-expire)
  • batch-license-pooled-transitions.test.ts — all three shapes decline to per_customer
  • bun tw sweep of licenses + pooled-balances + related reset paths

Made with Cursor


Summary by cubic

Adds pooled balance support to license plans. Previously pooled items on license links were rejected; now each license link mints a pool keyed by customer_license_link_id, and all seats under that link contribute to one shared balance.

New Features

  • Batch transitions handle seat add/remove, amount changes, and pooledness/identity flips in set-based SQL; flips (private↔pooled, interval change) drop usage and re-mint fresh.
  • Check, track, lazy reset, rollover, and the reset cron all read the license pool like any other pool, never the seat sources.
  • A dead license parent hides its pools from reads and reset hydration.
  • Removed the link-time rejection of pooled items on license plans.
  • Customer balance table now shows the parent plan name for license pooled balances.

Migration

  • Drops the pooled_balances_lifecycle_ids_valid check constraint so license pools can carry a customer_license_link_id.

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

Review in cubic

Greptile Summary

This PR adds pooled usage balances for license seats and carries them through lifecycle transitions, runtime reads, resets, migrations, and dashboard presentation.

  • [Improvements, API changes] License seats now contribute to a shared balance keyed by their customer license link.
  • [Improvements] Batch transitions add, remove, resize, and re-create pooled contributions when entitlement identity changes.
  • [Bug fixes, Improvements] Runtime and reset queries use the synthetic pool row and hide license pools whose parent is no longer live.
  • [Improvements] Customer balance screens resolve and display the plan behind pooled balances.
  • [API changes] The schema constraint that previously prohibited license-linked pool lifecycle identities is removed.

Confidence Score: 3/5

This PR should not merge until expiring a pooled license seat and removing its shared contribution are made atomic or reliably recoverable.

A failure after the seat status update can leave the seat expired while its stored contribution remains available to runtime usage checks.

Files Needing Attention: server/src/internal/licenses/actions/reconcile/expireUnusedAssignments.ts

Security Review

A partial-failure path can leave an expired license seat contributing capacity to a shared usage pool because seat expiration and contribution removal are separate transactions.

Important Files Changed

Filename Overview
server/src/internal/licenses/actions/reconcile/expireUnusedAssignments.ts Adds pooled-contribution removal for expired spare seats, but the seat and pool updates are not atomic.
server/src/internal/billing/v2/actions/batchTransition/compute/operations/entitlementPriceOperations/computeEntitlementPriceOperations.ts Splits pooled identity flips into remove/add operations and computes in-place contribution deltas for amount changes.
server/src/internal/billing/v2/actions/batchTransition/execute/sql/addCustomerEntitlementsBatch.ts Inserts seat entitlements and contributions while updating the shared grant and synthetic balance in one SQL statement.
server/src/internal/billing/v2/actions/batchTransition/execute/sql/pooledRemoveBatchCtes.ts Removes pooled contributions in batches and expires the pool when its final contribution disappears.
server/src/internal/billing/v2/actions/batchTransition/execute/sql/insertPooledBalanceGraph.ts Creates synthetic pool graph records and attempts identity-race recovery; no independently publishable production failure was established.
server/src/internal/customers/licensePooledBalanceIsLiveSql.ts Centralizes parent-product liveness filtering for license-linked pooled balances.
shared/models/pooledBalanceModels/pooledBalanceTable.ts Permits a customer license link on lifetime pools by removing the previous lifecycle-ID constraint.
vite/src/views/customers2/components/table/customer-balance/customerBalanceUtils.ts Resolves pooled-balance source plan names from customer license and contribution relationships.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[License seat created or updated] --> B[Seat pooled contribution]
  B --> C[Synthetic license pool balance]
  C --> D[Check and track usage]
  C --> E[Lazy or scheduled reset]
  F[License reconciliation] --> G[Expire unused seat]
  G --> H[Remove pooled contribution]
  H --> C
  G -. failure before removal .-> I[Expired seat still grants capacity]
Loading
Prompt To Fix All With AI
### Issue 1
server/src/internal/licenses/actions/reconcile/expireUnusedAssignments.ts:22-35
**Seat expiration leaves stale capacity**

When hydration or pooled-balance processing fails after the seat update succeeds, the seat remains expired while its contribution remains in the shared balance, so check and track requests can continue using capacity that the expired seat no longer grants. For example, failing after expiring a 10-unit seat leaves those 10 units available. **How this was verified:** The seat update commits before the separate pooled transition, while runtime reads the stored synthetic pool balance rather than recalculating it from live seats.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "Show the parent plan name for license po..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used (4)

johnyeocx and others added 3 commits August 28, 2026 19:25
Unblock pooled items on license plans, mint pools on seat attach, and hide
license-keyed pools at read time when the parent is no longer live.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replace patches contributions by delta; add mints the pool graph once and
inserts contributions per seat; remove deletes contributions and expires
drained pools. Same-feature pooledness or pool-identity changes route to
remove-then-add with fresh grants, keyed off the new shared
entToPooledBalanceIdentity derivation.

Co-authored-by: Cursor <cursoragent@cursor.com>
… like any other, and skip cron reset after the parent dies.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Aug 30, 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 Aug 30, 2026 3:45pm
landing-page Ignored Ignored Aug 30, 2026 3:45pm

Request Review

@johnyeocx
johnyeocx marked this pull request as ready for review August 30, 2026 15:44
@johnyeocx
johnyeocx requested a review from ay-rod as a code owner August 30, 2026 15:44
… table.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment on lines +22 to +35
const expiredSeats =
await licenseAssignmentRepo.expireUnusedAssignmentsByLinkIds({
db: ctx.db,
customerLicenseLinkIds: overAllocatedLinkIds,
endedAt,
});
if (expiredSeats.length === 0) return;

// Unassigned seats that are expired (eg, over-allocated pools) are outgoing
const outgoingCustomerProducts = await listFullCustomerProductsByIds({
db: ctx.db,
customerLicenseLinkIds: overAllocatedLinkIds,
endedAt: Date.now(),
customerProductIds: expiredSeats.map((seat) => seat.id),
});
await applyPooledBalanceCustomerProductTransitions({

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 security Seat expiration leaves stale capacity

When hydration or pooled-balance processing fails after the seat update succeeds, the seat remains expired while its contribution remains in the shared balance, so check and track requests can continue using capacity that the expired seat no longer grants. For example, failing after expiring a 10-unit seat leaves those 10 units available. How this was verified: The seat update commits before the separate pooled transition, while runtime reads the stored synthetic pool balance rather than recalculating it from live seats.

Knowledge Base Used: Event metering, balances, and insights

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/internal/licenses/actions/reconcile/expireUnusedAssignments.ts
Line: 22-35

Comment:
**Seat expiration leaves stale capacity**

When hydration or pooled-balance processing fails after the seat update succeeds, the seat remains expired while its contribution remains in the shared balance, so check and track requests can continue using capacity that the expired seat no longer grants. For example, failing after expiring a 10-unit seat leaves those 10 units available. **How this was verified:** The seat update commits before the separate pooled transition, while runtime reads the stored synthetic pool balance rather than recalculating it from live seats.

**Knowledge Base Used:** [Event metering, balances, and insights](https://app.greptile.com/autumn-org-2/-/custom-context/knowledge-base/useautumn/autumn/-/docs/event-metering-and-insights.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@capy-ai capy-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.

Capy found 4 potential issues (1 high, 3 medium).

View 2 other findings in Capy.

Open in Capy Review

internal_product_id: null,
internal_reward_id: null,
is_custom: true,
allowance_type: AllowanceType.Fixed,

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.

High: Batch-minted unlimited license pools stamp Fixed allowance_type on the synthetic entitlement, so they are not treated as unlimited.

When batch transition adds a pooled unlimited seat item (plan upgrade that introduces unlimited pooled usage, or a private to pooled flip onto unlimited), insertPooledBalanceGraph always writes allowance_type Fixed while only setting unlimited on the cusEnt and pool rows. The attach path initPooledBalanceGraph correctly uses AllowanceType.Unlimited. Call sites that key off entitlement.allowance_type alone (isUnlimitedCusEnt, isUnlimitedEntitlement, getApiBalanceV2, cusEntsToCurrentBalance, reset classification) treat the synthetic pool as a finite zero-balance entitlement. Track and check may still see ce.unlimited via the dual check in prepareFeatureDeduction, but customer balance APIs and reset classification disagree. Fix by mirroring initPooledBalanceGraph: set allowance_type Unlimited and allowance null when identity.unlimited is true, and null allowance fields for booleans.

Open in Capy Review

if (!isUniqueConstraintError(error)) throw error;
const racedId = await findLivePooledBalanceId({ db, identity });
if (!racedId) throw error;
return racedId;

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.

Medium: Race recovery in insertPooledBalanceGraph can commit orphan synthetic entitlement rows without a matching pool.

insertPooledBalanceGraph inserts entitlement, then synthetic customer_entitlement with a pre-generated pooled_balance_id and no FK, then pooled_balances. On a unique_pooled_balance race it catches 23505, looks up the winner pool id, and returns it, but the loser entitlement and cusEnt rows stay in the same batch transaction and commit. Those orphans are is_pooled_balance with customer_product_id null; hydration JOINs on pool id hide them from reads, but reset branch 1 left-joins pools by customer_entitlement_id (null) and licensePooledBalanceIsLiveSql treats a null link id as live, so an orphan with next_reset_at due can enter the cron scan. Wrap the three inserts in a savepoint and roll it back on unique failure, or delete the provisional rows before returning the raced id.

Open in Capy Review

customerEntitlements: customerProduct.customer_entitlements,
}).some(
(customerEntitlement) =>
customerEntitlement.pooled_balance_id === pooledBalanceId,

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.

Medium: Non-license pooled plan name lookup matches source cusEnts on pooled_balance_id, which is always null after write.

getRegularPooledBalancePlanName finds contributing products by comparing customerEntitlement.pooled_balance_id to the synthetic pool id. executePooledBalancePlan clears source pooled_balance_id and keeps the link only on pooled_balance_contribution.pooled_balance_id, and that contribution is hydrated on product cusEnts. The comparison never matches, planNames stays empty, and the UI falls through to Pooled or N/A for regular non-license synthetic pools in the balance table, edit sheet, and recalculate dialog. Match via customerEntitlement.pooled_balance_contribution?.pooled_balance_id instead.

Open in Capy Review

endedAt: Date.now(),
customerProductIds: expiredSeats.map((seat) => seat.id),
});
await applyPooledBalanceCustomerProductTransitions({

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.

Medium: Expiring over-allocated spare seats and removing their pool contributions are not in one transaction.

expireUnusedAssignments commits seat status Expired, then separately loads seats and runs applyPooledBalanceCustomerProductTransitions. If the second step fails or the process dies between them, the seat is expired while its contribution still increases pool granted, so check and track keep granting capacity the seat no longer should. Wrap expire and pool transition in a single DB transaction, or make reconcile idempotent so a later pass always drains contributions for already-expired unassigned seats.

Open in Capy Review

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