Skip to content

Commit ebd45d4

Browse files
committed
fix(wallets): cascade ongoing usage [ING-112]
## 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 and its FindApplicableOnFeesService and BuildAllocationRulesService helpers are removed, and pre-existing scenario specs documenting the old "whole fee to the first applicable wallet" behavior are updated to the new billing-parity cascade.
1 parent c220386 commit ebd45d4

15 files changed

Lines changed: 668 additions & 925 deletions

app/jobs/customers/refresh_wallet_job.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@ class RefreshWalletJob < ApplicationJob
1818
retry_on(*Integrations::Aggregator::BaseService.retryable_errors, wait: :polynomially_longer, attempts: 6)
1919

2020
def perform(customer, wallet_ids: nil)
21-
# When targeting specific wallets, the refresh is explicitly requested (e.g. balance increase)
22-
# so we don't rely on the customer-wide awaiting_wallet_refresh flag.
21+
# wallet_ids marks an explicitly requested refresh (e.g. balance increase) that must run
22+
# even when the customer-wide awaiting_wallet_refresh flag is not set. The refresh itself
23+
# always covers every wallet: the cascade makes allocations interdependent.
2324
return if wallet_ids.nil? && !customer.awaiting_wallet_refresh?
2425
return if customer.error_details.tax_error.exists?
2526

26-
Customers::RefreshWalletsService.call!(customer:, target_wallet_ids: wallet_ids)
27+
Customers::RefreshWalletsService.call!(customer:)
2728
rescue BaseService::ValidationFailure => e
2829
tax_error = Array(e.messages[:tax_error])
2930

app/services/customers/refresh_wallets_service.rb

Lines changed: 48 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,68 +2,39 @@
22

33
module Customers
44
class RefreshWalletsService < BaseService
5-
Result = BaseResult[:usage_amount_cents, :wallets, :allocation_rules]
5+
Result = BaseResult[:wallets]
66

7-
def initialize(customer:, include_generating_invoices: false, target_wallet_ids: nil)
7+
def initialize(customer:, include_generating_invoices: false)
88
@customer = customer
99
@include_generating_invoices = include_generating_invoices
10-
@target_wallet_ids = target_wallet_ids
1110

1211
super
1312
end
1413

1514
def call
16-
usage_amount_cents = customer.active_subscriptions.map do |subscription|
17-
invoice = ::Invoices::CustomerUsageService.call!(customer:, subscription:, usage_filters: UsageFilters::WITHOUT_PRESENTATION_FILTER).invoice
18-
19-
billed_progressive_invoice_subscriptions = ::Subscriptions::ProgressiveBilledAmount
20-
.call(subscription:, include_generating_invoices:)
21-
.invoice_subscriptions
22-
23-
{
24-
billed_progressive_invoice_subscriptions:,
25-
invoice:,
26-
subscription:
27-
}
15+
wallet_allocations = Wallets::Balance::AllocateOngoingUsageByWalletsService.call!(
16+
customer:,
17+
wallets: all_wallets,
18+
current_usage_fees:,
19+
draft_invoices_fees:,
20+
progressive_billing_fees:,
21+
pay_in_advance_fees:
22+
).wallet_allocations
23+
24+
# The cascade makes every wallet's allocation depend on the others' balances, so all
25+
# wallets must be persisted together; refreshing a subset would leave the rest stale.
26+
all_wallets.each do |wallet|
27+
Wallets::Balance::RefreshOngoingUsageService.call!(
28+
wallet:,
29+
ongoing_usage_amount_cents: wallet_allocations[wallet],
30+
skip_single_wallet_update: true
31+
)
2832
end
2933

30-
@allocation_rules = Wallets::BuildAllocationRulesService.call!(customer:).allocation_rules
31-
32-
# we need to get both: ALL fees and wallets_applicable_on_fees ({ fee_key => wallet_id, ... }) per each fee type
33-
# to not rebuild wallet assigning per each fee when going per each separate wallet
34-
usage_fees = usage_amount_cents.flat_map { |usage| usage[:invoice].fees }
35-
wallets_applicable_on_usage_fees = assign_wallet_per_fee(usage_fees) # { usage_fee_key => wallet_id }
36-
37-
draft_invoice_fees = customer.invoices.draft.where.not(total_amount_cents: 0).includes(fees: :charge).flat_map(&:fees)
38-
wallets_applicable_on_draft_fees = assign_wallet_per_fee(draft_invoice_fees) # { draft_fee_key => wallet_id }
39-
40-
progressive_billing_fees = usage_amount_cents.flat_map { |usage| usage[:billed_progressive_invoice_subscriptions].flat_map { it.invoice.fees } }
41-
wallets_applicable_on_pb_fees = assign_wallet_per_fee(progressive_billing_fees) # { pb_fee_key => wallet_id }
42-
43-
pay_in_advance_fees = usage_amount_cents.flat_map { |usage| usage[:invoice].fees.select { |f| f.charge.pay_in_advance? } }
44-
wallets_applicable_on_adv_fees = assign_wallet_per_fee(pay_in_advance_fees) # { adv_fee_key => wallet_id }
45-
46-
wallets_to_process = customer.wallets.active.includes(:recurring_transaction_rules)
47-
wallets_to_process = wallets_to_process.where(id: target_wallet_ids) if target_wallet_ids
48-
wallets_to_process.find_in_batches(batch_size: 100) do |wallets|
49-
wallets.each do |wallet|
50-
Wallets::Balance::RefreshOngoingUsageService.call!(
51-
wallet:,
52-
usage_amount_cents:,
53-
skip_single_wallet_update: true,
54-
current_usage_fees: applicable_fees(usage_fees, wallets_applicable_on_usage_fees, wallet),
55-
draft_invoices_fees: applicable_fees(draft_invoice_fees, wallets_applicable_on_draft_fees, wallet),
56-
progressive_billing_fees: applicable_fees(progressive_billing_fees, wallets_applicable_on_pb_fees, wallet),
57-
pay_in_advance_fees: applicable_fees(pay_in_advance_fees, wallets_applicable_on_adv_fees, wallet)
58-
)
59-
end
60-
end
61-
wallets_to_process.update_all(last_ongoing_balance_sync_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
34+
Wallet.where(id: all_wallets.map(&:id)).update_all(last_ongoing_balance_sync_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
6235

6336
customer.update!(awaiting_wallet_refresh: false)
6437

65-
result.usage_amount_cents = usage_amount_cents
66-
result.allocation_rules = allocation_rules
6738
result.wallets = customer.wallets.active.reload
6839
result
6940
rescue BaseService::FailedResult => e
@@ -72,32 +43,41 @@ def call
7243

7344
private
7445

75-
attr_reader :customer, :include_generating_invoices, :allocation_rules, :target_wallet_ids
46+
attr_reader :customer, :include_generating_invoices
7647

77-
def assign_wallet_per_fee(fees)
78-
fee_wallet = {}
79-
fee_targeting_wallets_enabled = customer.organization.events_targeting_wallets_enabled?
48+
def all_wallets
49+
@all_wallets ||= customer.wallets.active.includes(:recurring_transaction_rules, :wallet_targets).in_application_order.to_a
50+
end
8051

81-
fees.each do |fee|
82-
key = fee.item_key
52+
def current_usage_fees
53+
@current_usage_fees ||= subscription_usages.flat_map { |usage| usage[:invoice].fees }
54+
end
8355

84-
if fee_targeting_wallets_enabled && fee.charge&.accepts_target_wallet && fee&.grouped_by&.dig("target_wallet_code").present?
85-
targeted_wallet = customer.wallets.active.where(code: fee.grouped_by["target_wallet_code"]).ids.first
86-
fee_wallet[key] = targeted_wallet
87-
next if targeted_wallet
88-
end
56+
# pay-in-advance fees are a subset of the current usage fees, so filter rather than re-walk.
57+
def pay_in_advance_fees
58+
current_usage_fees.select { |fee| fee.charge.pay_in_advance? }
59+
end
8960

90-
applicable_wallets = Wallets::FindApplicableOnFeesService
91-
.call!(allocation_rules: allocation_rules, fee:, customer_id: customer.id, fee_targeting_wallets_enabled:)
92-
.top_priority_wallet
93-
fee_wallet[key] = applicable_wallets.presence
94-
end
61+
def draft_invoices_fees
62+
customer.invoices.draft.where.not(total_amount_cents: 0).includes(fees: :charge).flat_map(&:fees)
63+
end
9564

96-
fee_wallet
65+
def progressive_billing_fees
66+
subscription_usages.flat_map { |usage| usage[:billed_progressive_invoice_subscriptions].flat_map { it.invoice.fees.includes(:charge) } }
9767
end
9868

99-
def applicable_fees(fees, fee_map, wallet)
100-
fees.select { |fee| fee_map[fee.item_key] == wallet.id && fee.amount_currency == wallet.balance_currency }
69+
# One entry per active subscription: its current-usage invoice and the progressively
70+
# billed invoice subscriptions used to net already-billed amounts out of ongoing usage.
71+
def subscription_usages
72+
@subscription_usages ||= customer.active_subscriptions.map do |subscription|
73+
invoice = ::Invoices::CustomerUsageService.call!(customer:, subscription:, usage_filters: UsageFilters::WITHOUT_PRESENTATION_FILTER).invoice
74+
75+
billed_progressive_invoice_subscriptions = ::Subscriptions::ProgressiveBilledAmount
76+
.call(subscription:, include_generating_invoices:)
77+
.invoice_subscriptions
78+
79+
{billed_progressive_invoice_subscriptions:, invoice:, subscription:}
80+
end
10181
end
10282
end
10383
end
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# frozen_string_literal: true
2+
3+
module Wallets
4+
module Balance
5+
# Distributes ongoing (unbilled) usage across the customer's wallets in priority order,
6+
# mirroring Credits::AllocatePrepaidCreditsByWalletsService. A wallet with an active
7+
# threshold-based recurring rule is the exception: it absorbs everything and may go
8+
# negative (no cascade) so the rule can fire and refill it.
9+
class AllocateOngoingUsageByWalletsService < BaseService
10+
Result = BaseResult[:wallet_allocations]
11+
12+
def initialize(customer:, wallets:, current_usage_fees:, draft_invoices_fees:, progressive_billing_fees:, pay_in_advance_fees:)
13+
@customer = customer
14+
@wallets = wallets
15+
@current_usage_fees = current_usage_fees
16+
@draft_invoices_fees = draft_invoices_fees
17+
@progressive_billing_fees = progressive_billing_fees
18+
@pay_in_advance_fees = pay_in_advance_fees
19+
20+
super
21+
end
22+
23+
def call
24+
result.wallet_allocations = calculate_wallet_allocations
25+
result
26+
end
27+
28+
private
29+
30+
attr_reader :customer, :wallets, :current_usage_fees, :draft_invoices_fees,
31+
:progressive_billing_fees, :pay_in_advance_fees
32+
33+
def calculate_wallet_allocations
34+
net_amounts = net_usage_by_fee_key
35+
budgets = currency_budgets(net_amounts)
36+
metas = wallets.map { |wallet| wallet_meta(wallet) }
37+
allocations = wallets.index_with(0)
38+
39+
allocatable_pool(net_amounts).each do |fee_key, key_amount|
40+
currency = fee_key.last
41+
remaining = [key_amount, budgets[currency]].min
42+
applicable = metas.select { |meta| applicable_fee?(fee_key:, wallet: meta[:wallet], targets: meta[:targets], types: meta[:types]) }
43+
44+
applicable.each_with_index do |meta, index|
45+
break if remaining <= 0
46+
47+
if meta[:threshold] || index == applicable.length - 1
48+
# A threshold wallet absorbs everything so its rule can refill it; the last
49+
# applicable wallet absorbs the overflow. Both are allowed to go negative.
50+
take = remaining
51+
else
52+
room = meta[:balance] - allocations[meta[:wallet]]
53+
next if room <= 0
54+
take = [remaining, room].min
55+
end
56+
57+
allocations[meta[:wallet]] += take
58+
remaining -= take
59+
budgets[currency] -= take
60+
end
61+
end
62+
63+
allocations
64+
end
65+
66+
def wallet_meta(wallet)
67+
threshold = threshold_wallet?(wallet)
68+
{
69+
wallet:,
70+
targets: wallet.wallet_targets.filter_map { |wt| ["charge", wt.billable_metric_id] if wt.billable_metric_id },
71+
types: wallet.allowed_fee_types,
72+
threshold:,
73+
# Re-read the balance so a concurrent pay-in-advance DecreaseService can't make us cap
74+
# against a stale value. Balance only, to keep the eager-loaded associations.
75+
balance: threshold ? 0 : Wallet.where(id: wallet.id).pick(:balance_cents)
76+
}
77+
end
78+
79+
# Net the fee buckets into a signed amount per fee key. Keys whose net is <= 0
80+
# (already fully billed) cannot receive allocations, but their negative nets still
81+
# reduce the per-currency budget the same way billing credits reduce the invoice total.
82+
def net_usage_by_fee_key
83+
net_amounts = Hash.new(0)
84+
85+
add_to_pool(net_amounts, current_usage_fees) { |fee| fee.amount_cents + fee.taxes_amount_cents }
86+
add_to_pool(net_amounts, draft_invoices_fees) { |fee| fee.amount_cents + fee.taxes_amount_cents - fee.precise_coupons_amount_cents }
87+
add_to_pool(net_amounts, progressive_billing_fees) { |fee| -(fee.sub_total_excluding_taxes_amount_cents + fee.taxes_amount_cents) }
88+
add_to_pool(net_amounts, pay_in_advance_fees) { |fee| -(fee.amount_cents + fee.taxes_amount_cents) }
89+
90+
net_amounts
91+
end
92+
93+
def allocatable_pool(net_amounts)
94+
net_amounts
95+
.select { |_, amount| amount.positive? }
96+
# Ties broken by fee key so the allocation order is stable across refreshes.
97+
.sort_by { |fee_key, amount| [-amount, fee_key.map(&:to_s)] }
98+
.to_h
99+
end
100+
101+
# Mirrors billing's remaining_invoice_amount: an over-billed key's negative net offsets
102+
# the other keys in its currency, the same way credits offset the invoice at billing.
103+
def currency_budgets(net_amounts)
104+
budgets = Hash.new(0)
105+
net_amounts.each { |fee_key, amount| budgets[fee_key.last] += amount }
106+
budgets.transform_values! { |amount| [amount, 0].max }
107+
end
108+
109+
def add_to_pool(remaining, fees)
110+
fees.each { |fee| remaining[fee_key(fee)] += yield(fee) }
111+
end
112+
113+
def fee_key(fee)
114+
target_wallet_code = if fee_targeting_wallets_enabled? && fee.charge&.accepts_target_wallet
115+
fee.grouped_by&.dig("target_wallet_code")
116+
end
117+
118+
[fee.fee_type, fee.charge&.billable_metric_id, target_wallet_code, fee.amount_currency]
119+
end
120+
121+
def applicable_fee?(fee_key:, wallet:, targets:, types:)
122+
fee_type, _billable_metric_id, target_wallet_code, currency = fee_key
123+
124+
return false unless wallet.balance_currency == currency
125+
return wallet.code == target_wallet_code if target_wallet_code.present?
126+
127+
target_match = targets.include?(fee_key.first(2))
128+
type_match = types.include?(fee_type)
129+
unrestricted_wallet = targets.empty? && types.empty?
130+
131+
target_match || type_match || unrestricted_wallet
132+
end
133+
134+
def threshold_wallet?(wallet)
135+
wallet.recurring_transaction_rules.any? { |rule| rule.currently_active? && rule.threshold? }
136+
end
137+
138+
def fee_targeting_wallets_enabled?
139+
return @fee_targeting_wallets_enabled if defined?(@fee_targeting_wallets_enabled)
140+
141+
@fee_targeting_wallets_enabled = customer.organization.events_targeting_wallets_enabled?
142+
end
143+
end
144+
end
145+
end

app/services/wallets/balance/refresh_ongoing_usage_service.rb

Lines changed: 4 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,15 @@ module Balance
55
class RefreshOngoingUsageService < BaseService
66
Result = BaseResult[:wallet]
77

8-
def initialize(wallet:, usage_amount_cents:, current_usage_fees:, draft_invoices_fees:, progressive_billing_fees:, pay_in_advance_fees:, skip_single_wallet_update: false)
8+
def initialize(wallet:, ongoing_usage_amount_cents:, skip_single_wallet_update: false)
99
@wallet = wallet
10-
@usage_amount_cents = usage_amount_cents
10+
@ongoing_usage_amount_cents = ongoing_usage_amount_cents
1111
@skip_single_wallet_update = skip_single_wallet_update
12-
@current_usage_fees = current_usage_fees
13-
@draft_invoices_fees = draft_invoices_fees
14-
@progressive_billing_fees = progressive_billing_fees
15-
@pay_in_advance_fees = pay_in_advance_fees
1612

1713
super
1814
end
1915

2016
def call
21-
@total_usage_amount_cents = calculate_total_usage_with_limitation
22-
@total_billed_usage_amount_cents = calculate_total_billed_usage_amount_cents
23-
2417
# Before this service is called, the wallet is already loaded in the memory. If while calculating current usage we received
2518
# a pay_in_advance_fee, wallet will be updated by Wallets::Balance::DecreaseService and current wallet version will throw an
2619
# `Attempted to update a stale object` error. To avoid this, we reload the wallet before updating it.
@@ -35,37 +28,7 @@ def call
3528

3629
private
3730

38-
attr_reader :wallet, :total_usage_amount_cents, :total_billed_usage_amount_cents, :usage_amount_cents, :skip_single_wallet_update,
39-
:current_usage_fees, :draft_invoices_fees, :progressive_billing_fees, :pay_in_advance_fees
40-
41-
delegate :customer, to: :wallet
42-
43-
def calculate_total_billed_usage_amount_cents
44-
billed_progressive_invoices_amount_cents +
45-
billed_pay_in_advance_amount_cents
46-
end
47-
48-
def billed_progressive_invoices_amount_cents
49-
progressive_billing_fees.sum do |fee|
50-
fee.taxes_amount_cents + fee.sub_total_excluding_taxes_amount_cents
51-
end
52-
end
53-
54-
def draft_invoices_total_amount_cents
55-
draft_invoices_fees.sum do |fee|
56-
fee.amount_cents + fee.taxes_amount_cents - fee.precise_coupons_amount_cents
57-
end
58-
end
59-
60-
def billed_pay_in_advance_amount_cents
61-
# Invoice that is returned from CustomerUsageService includes the taxes in total_usage
62-
# so if the fees ae already paid, we should exclude fees AND their taxes
63-
pay_in_advance_fees.sum { |fee| fee.amount_cents + fee.taxes_amount_cents }
64-
end
65-
66-
def calculate_total_usage_with_limitation
67-
current_usage_fees.sum { |fee| fee.amount_cents + fee.taxes_amount_cents }
68-
end
31+
attr_reader :wallet, :ongoing_usage_amount_cents, :skip_single_wallet_update
6932

7033
def wallet_update_params
7134
params = {
@@ -89,9 +52,7 @@ def currency
8952
end
9053

9154
def ongoing_usage_balance_cents
92-
@ongoing_usage_balance_cents ||= total_usage_amount_cents +
93-
draft_invoices_total_amount_cents -
94-
total_billed_usage_amount_cents
55+
ongoing_usage_amount_cents
9556
end
9657

9758
def credits_ongoing_usage_balance

0 commit comments

Comments
 (0)