Skip to content

[ING-112] Cascade ongoing usage across wallets#5825

Merged
aquinofb merged 4 commits into
mainfrom
ing-112
Jul 17, 2026
Merged

[ING-112] Cascade ongoing usage across wallets#5825
aquinofb merged 4 commits into
mainfrom
ing-112

Conversation

@aquinofb

@aquinofb aquinofb commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Context

Lago consumes wallet credits through two paths that diverge for multi-wallet customers. Billing already cascades across wallets in priority order, but ongoing usage stopped at the first wallet, taking it negative instead of spilling onto the next. The negative ongoing balance is customer-visible (UI, API, threshold rules) and breaks the two-wallet (paid + free) setup.

Linear: https://linear.app/getlago/issue/ING-112

Behavior

Wallet B = priority 1 (consumed first), Wallet A = priority 2. Ongoing balance per wallet after the given usage:

Wallets (priority order) Usage Before After
Single wallet, balance 50 30 20 20 (unchanged)
Single wallet, balance 50 80 −30 −30 (unchanged)
B 50 + A 150 80 B −30, A 150 B 0, A 120
B 50 + A 150 250 B −200, A 150 B 0, A −50
B 50 (threshold rule) + A 150 80 B −30, A 150 B −30, A 150 (unchanged)
  • Single wallet is unchanged: it still absorbs everything and may go negative.
  • Multiple wallets now cascade in priority order -- a wallet fills to 0 before the next is touched (row 3, the headline case: matches what billing produces).
  • When usage exceeds every wallet, the last wallet absorbs the overflow and goes negative (row 4) instead of the first -- nothing is silently dropped.
  • A wallet with an active threshold recurring rule keeps today's behavior: it absorbs everything and goes negative so the rule can fire and refill it (row 5).

Changes

  • Add Wallets::Balance::AllocateOngoingUsageByWalletsService, mirroring the billing cascade (Credits::AllocatePrepaidCreditsByWalletsService): net usage per fee, distribute across active wallets in priority order, fill each to its balance, spill the overflow, and let the last applicable wallet go negative. An over-billed fee key reduces a per-currency budget the same way billing credits reduce the invoice total.
  • A wallet with an active threshold-based recurring rule keeps the previous behavior (absorbs everything, may go negative) so the rule can fire and refill it.
  • RefreshWalletsService runs the allocator once and feeds each wallet its precomputed amount. Because the cascade makes allocations interdependent, every refresh persists all wallets; the previous target_wallet_ids narrowing would leave non-target wallets stale.
  • The old single-wallet assignment is replaced, which leaves FindApplicableOnFeesService and BuildAllocationRulesService without callers. They are deleted in [ING-112] Remove unused wallet allocation services #5884 (~600 lines of pure deletion) to keep this one focused on the behavior change.

Validation

The specs from this branch (they encode the expected cascade behavior) were run against main and against this branch -- same 46 examples:

Codebase Result
main (old allocation) 46 examples, 10 failures -- every multi-wallet cascade scenario
this branch 46 examples, 0 failures

Per-scenario ongoing balances (each row is one failing-then-fixed spec):

Scenario On main On this branch
B (50, priority 1) + A (150), usage 80 -- the ticket example B -30, A 150 B 0, A 120
4 wallets (10/20/30/40), each limited to 2 metrics, overlapping; usage 100 W1 -20, W2 -10, W3 -10, W4 idle at 40 W1 0, W2 -10, W3 -10, W4 20
4 wallets, three limited to charge fees; charge usage 100 W2 absorbs all: -80; W3, W4 idle W2 0, W3 0, W4 -10
4 unrestricted wallets; net usage 90 (10 already progressively billed) W1 -90; W2, W3, W4 idle cascades W2, W3, W4 -- all land at 0
Wallet targeted to metric 1 (1000) + catch-all wallet; metric-1 usage 3000 targeted -2000; catch-all untouched targeted 0; catch-all absorbs the overflow (-1000)
Two wallets (20000 + 25000), usage 35000; billing deducts 20000 + 15000 ongoing shows 35000 on wallet 1, 0 on wallet 2 -- diverges from billing ongoing shows 20000 + 15000 -- identical to what billing deducts

Preserved behavior passes on both codebases: single wallet going negative, threshold-rule wallets absorbing and refilling, and ongoing-balance alerts firing as the balance crosses into negative.

Edge cases pinned by the new allocator unit suite (the service does not exist on main, so no old-code column):

Edge case Behavior
Wallet with an active interval (non-threshold) recurring rule cascades like a normal wallet -- only threshold rules absorb and refill
Threshold wallet that is not first in priority higher-priority wallets fill first, then it absorbs the remainder and stops the cascade
Zero-balance wallet in the middle of the cascade skipped, never forced negative
One metric over-billed by progressive billing (usage corrected down) its excess offsets the other metrics, like billing credits offset the invoice
Fee already fully covered by progressive billing nothing is allocated
Mixed-currency wallets a fee is only absorbed by wallets in its currency
Fee targeting a wallet code that matches no wallet allocated to no wallet, matching billing where no wallet covers it
Balance changed concurrently (stale in-memory value) the cascade caps against the balance re-read from the database

@aquinofb aquinofb self-assigned this Jul 1, 2026
@aquinofb
aquinofb force-pushed the ing-112 branch 5 times, most recently from ebd45d4 to d5a4324 Compare July 5, 2026 02:15
@aquinofb
aquinofb marked this pull request as ready for review July 6, 2026 14:49
@aquinofb
aquinofb force-pushed the ing-112 branch 2 times, most recently from 2461bfe to b599a37 Compare July 6, 2026 15:23
@aquinofb
aquinofb requested review from a team, annvelents, groyoh, mariohd and vincent-pochet and removed request for a team July 6, 2026 19:51
@groyoh
groyoh requested review from brunomiguelpinto and removed request for annvelents July 8, 2026 06:35
@brunomiguelpinto

Copy link
Copy Markdown
Contributor

we should extract the allocation logic to be used in the 2 lanes to be easier to maintain

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

I haven't been able to look deeply into the business logic but the code coverage looks sound so I'll trust that.

Comment thread app/services/customers/refresh_wallets_service.rb Outdated
Comment thread spec/scenarios/wallets/customer_wallets_balance_refresh_spec.rb
Comment thread spec/scenarios/wallets/customer_wallets_balance_refresh_spec.rb
Comment thread spec/scenarios/wallets/customer_wallets_balance_refresh_spec.rb
Comment thread spec/scenarios/wallets/customer_wallets_balance_refresh_spec.rb
Comment thread app/services/wallets/balance/allocate_ongoing_usage_by_wallets_service.rb Outdated
aquinofb added 3 commits July 16, 2026 09:03
## Context

Lago consumes wallet credits through two paths that diverge for
multi-wallet customers. Billing already cascades across wallets in
priority order, but ongoing usage stopped at the first wallet,
absorbing all usage there and going negative instead of spilling onto
the next. The negative ongoing balance is customer-visible and breaks
the two-wallet (paid + free) setup.

## Description

Add Wallets::Balance::AllocateOngoingUsageByWalletsService, which
mirrors the billing-side cascade: it nets current, draft, progressive
and pay-in-advance usage per fee, then distributes it across the
customer's active wallets in priority order. Each applicable wallet
fills to its balance and spills the overflow onto the next; the last
applicable wallet absorbs the remainder and may go negative, since
ongoing usage has no invoice to carry the excess. An over-billed fee
key reduces a per-currency budget the same way billing credits reduce
the invoice total, so wallets never absorb more than the customer's
net unbilled usage. A wallet with an active threshold-based recurring
rule keeps the previous behavior (absorbs everything, may go negative)
so the rule can fire and refill it.

RefreshWalletsService runs the allocator once and feeds each wallet its
precomputed amount. Because the cascade makes allocations
interdependent, every refresh now persists all wallets: the previous
target_wallet_ids narrowing would leave non-target wallets stale. The
per-fee single-wallet assignment is replaced, which leaves
FindApplicableOnFeesService and BuildAllocationRulesService without
callers; they are removed in a follow-up PR to keep this one focused.
Pre-existing scenario specs documenting the old "whole fee to the
first applicable wallet" behavior are updated to the new billing-parity
cascade.
## Context

Review feedback on the cascade PR.

## Description

Touch updated_at alongside last_ongoing_balance_sync_at in the bulk
wallet sync, so wallets whose ongoing values did not change do not
keep a stale updated_at. Assert the catch-all wallet in every cascade
scenario, pin that a fee targeting an underfunded wallet goes negative
on that wallet without spilling, and add visual headers to the cascade
scenarios.
## Context

Review follow-up: touch_all sets updated_at together with the named
column in one statement, replacing the manual update_all pair.
aquinofb added a commit that referenced this pull request Jul 16, 2026
## Context

The ongoing-usage cascade (#5825) replaced the per-fee single-wallet
assignment. FindApplicableOnFeesService and BuildAllocationRulesService
lost their only caller there and are now dead code.

## Description

Delete both services and their specs. No other code references them.

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

Amazing work on refactoring! 👏 🤩
left just one comment

Comment thread app/services/wallets/balance/allocate_ongoing_usage_by_wallets_service.rb Outdated
## Context

Review feedback: some organizations have customers with hundreds of
wallets and send usage frequently, so their wallets refresh often.
The allocator re-read each wallet's balance with one query per wallet.

## Description

Read all balances up front with a single query. Freshness is the same
because the per-wallet reads already happened before the allocation
loop, and a single statement gives one consistent snapshot across
wallets instead of staggered reads.
aquinofb added a commit that referenced this pull request Jul 17, 2026
## Context

The ongoing-usage cascade (#5825) replaced the per-fee single-wallet
assignment. FindApplicableOnFeesService and BuildAllocationRulesService
lost their only caller there and are now dead code.

## Description

Delete both services and their specs. No other code references them.
@aquinofb
aquinofb merged commit acb5672 into main Jul 17, 2026
12 checks passed
@aquinofb
aquinofb deleted the ing-112 branch July 17, 2026 12:58
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.

5 participants