License pooled balances: lifecycle, batch transitions, and runtime - #3162
License pooled balances: lifecycle, batch transitions, and runtime#3162johnyeocx wants to merge 4 commits into
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
… table. Co-authored-by: Cursor <cursoragent@cursor.com>
| 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({ |
There was a problem hiding this 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
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.| internal_product_id: null, | ||
| internal_reward_id: null, | ||
| is_custom: true, | ||
| allowance_type: AllowanceType.Fixed, |
There was a problem hiding this comment.
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.
| if (!isUniqueConstraintError(error)) throw error; | ||
| const racedId = await findLivePooledBalanceId({ db, identity }); | ||
| if (!racedId) throw error; | ||
| return racedId; |
There was a problem hiding this comment.
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.
| customerEntitlements: customerProduct.customer_entitlements, | ||
| }).some( | ||
| (customerEntitlement) => | ||
| customerEntitlement.pooled_balance_id === pooledBalanceId, |
There was a problem hiding this comment.
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.
| endedAt: Date.now(), | ||
| customerProductIds: expiredSeats.map((seat) => seat.id), | ||
| }); | ||
| await applyPooledBalanceCustomerProductTransitions({ |
There was a problem hiding this comment.
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.
Summary
customer_license_link_id; batchTransition handles amount-change, add/remove, and pooledness/identity flips in set-based SQL.per_customerlane.Test plan
license-pooled-*undertests/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 toper_customerbun twsweep of licenses + pooled-balances + related reset pathsMade 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
Migration
pooled_balances_lifecycle_ids_validcheck constraint so license pools can carry acustomer_license_link_id.Written for commit 6642263. Summary will update on new commits.
Greptile Summary
This PR adds pooled usage balances for license seats and carries them through lifecycle transitions, runtime reads, resets, migrations, and dashboard presentation.
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
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]Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "Show the parent plan name for license po..." | Re-trigger Greptile
Context used (4)