Skip to content

Commit 05587e4

Browse files
feat(wallets): Support multiple wallets (#4678)
## Context We’re introducing the priority field to wallets to support custom execution or display ordering. This PR builds on the base work from #4391, which added the field to the Wallet model and exposed it via the API. The goal of this PR is to implement the logic that uses the priority value when handling wallets — for example, when determining which wallets to apply or process first. ## Description This PR includes: • Logic to order wallets by priority (and created_at as a secondary key). • Ensures active_wallets_in_application_order returns wallets in the correct priority sequence. • Updates any internal wallet processing to respect the defined order. This builds directly on the foundation laid in PR #4391 and assumes the priority field and its validations are already in place. --------- Co-authored-by: Miguel Pinto <darkymiguel@gmail.com>
1 parent c228fbc commit 05587e4

19 files changed

+796
-208
lines changed

.junie/guidelines.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

app/services/credits/applied_prepaid_credits_service.rb

Lines changed: 90 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -6,52 +6,113 @@ class AppliedPrepaidCreditsService < BaseService
66

77
def initialize(invoice:, wallets:, max_wallet_decrease_attempts: DEFAULT_MAX_WALLET_DECREASE_ATTEMPTS)
88
@invoice = invoice
9-
@wallet = wallets.first
9+
@wallets = wallets
1010
@max_wallet_decrease_attempts = max_wallet_decrease_attempts
1111
raise ArgumentError, "max_wallet_decrease_attempts must be between 1 and #{DEFAULT_MAX_WALLET_DECREASE_ATTEMPTS} (inclusive)" if max_wallet_decrease_attempts < 1 || max_wallet_decrease_attempts > DEFAULT_MAX_WALLET_DECREASE_ATTEMPTS
1212

1313
super(nil)
1414
end
1515

16-
activity_loggable(
17-
action: "wallet_transaction.created",
18-
record: -> { result.wallet_transaction }
19-
)
20-
2116
def call
22-
if already_applied?
17+
if wallets_already_applied?
2318
return result.service_failure!(code: "already_applied", message: "Prepaid credits already applied")
2419
end
2520

26-
amount_cents = compute_amount
27-
28-
ApplicationRecord.transaction do
29-
wallet_transaction = create_wallet_transaction(amount_cents)
30-
result.wallet_transaction = wallet_transaction
31-
amount_cents = wallet_transaction.amount_cents
32-
33-
with_optimistic_lock_retry(wallet) do
34-
Wallets::Balance::DecreaseService.call(wallet:, wallet_transaction:)
21+
result.prepaid_credit_amount_cents ||= 0
22+
result.wallet_transactions ||= []
23+
24+
ActiveRecord::Base.transaction do
25+
ordered_remaining_amounts = calculate_amounts_for_fees_by_type_and_bm
26+
wallets.each do |wallet|
27+
wallet.reload
28+
wallet_fee_transactions = []
29+
wallet_targets_array = wallet.wallet_targets.map do |wt|
30+
if wt&.billable_metric_id
31+
["charge", wt.billable_metric_id]
32+
end
33+
end
34+
wallet_types_array = wallet.allowed_fee_types
35+
36+
ordered_remaining_amounts.each do |fee_key, remaining_amount|
37+
next if remaining_amount <= 0
38+
39+
next unless applicable_fee?(fee_key:, targets: wallet_targets_array, types: wallet_types_array)
40+
41+
used_amount = wallet_fee_transactions.sum { |t| t[:amount_cents] }
42+
remaining_wallet_balance = wallet.balance_cents - used_amount
43+
next if remaining_wallet_balance <= 0
44+
45+
transaction_amount = [remaining_amount, remaining_wallet_balance].min
46+
next if transaction_amount <= 0
47+
48+
ordered_remaining_amounts[fee_key] -= transaction_amount
49+
wallet_fee_transactions << {
50+
fee_key: fee_key,
51+
amount_cents: transaction_amount
52+
}
53+
end
54+
55+
total_amount_cents = wallet_fee_transactions.sum { |t| t[:amount_cents] }
56+
next if total_amount_cents <= 0
57+
58+
wallet_transaction = create_wallet_transaction(wallet, total_amount_cents)
59+
amount_cents = wallet_transaction.amount_cents
60+
61+
with_optimistic_lock_retry(wallet) do
62+
Wallets::Balance::DecreaseService.call(wallet:, wallet_transaction:, skip_refresh: true)
63+
end
64+
65+
result.wallet_transactions << wallet_transaction
66+
result.prepaid_credit_amount_cents += amount_cents
67+
invoice.prepaid_credit_amount_cents += amount_cents
3568
end
36-
end
3769

38-
result.prepaid_credit_amount_cents = amount_cents
39-
invoice.prepaid_credit_amount_cents += amount_cents
40-
41-
SendWebhookJob.perform_after_commit("wallet_transaction.created", result.wallet_transaction)
70+
Customers::RefreshWalletsService.call(customer:, include_generating_invoices: true)
71+
invoice.save! if invoice.changed?
72+
end
4273

74+
schedule_webhook_notifications(result.wallet_transactions)
4375
result
4476
rescue ActiveRecord::RecordInvalid => e
4577
result.record_validation_failure!(record: e.record)
4678
end
4779

4880
private
4981

50-
attr_accessor :invoice, :wallet, :max_wallet_decrease_attempts
82+
attr_accessor :invoice, :wallets, :max_wallet_decrease_attempts
5183

52-
delegate :balance_cents, to: :wallet
84+
delegate :customer, to: :invoice
85+
86+
def schedule_webhook_notifications(wallet_transactions)
87+
wallet_transactions.each do |wt|
88+
Utils::ActivityLog.produce_after_commit(wt, "wallet_transaction.created")
89+
SendWebhookJob.perform_after_commit("wallet_transaction.created", wt)
90+
end
91+
end
92+
93+
def calculate_amounts_for_fees_by_type_and_bm
94+
remaining = Hash.new(0)
95+
96+
invoice.fees.includes(:charge).find_each do |fee|
97+
cap = fee.sub_total_excluding_taxes_amount_cents +
98+
fee.taxes_precise_amount_cents -
99+
fee.precise_credit_notes_amount_cents
100+
101+
next if cap <= 0
102+
key = [fee.fee_type, fee.charge&.billable_metric_id]
103+
remaining[key] += cap
104+
end
105+
106+
remaining.sort_by { |_, v| -v }.to_h
107+
end
53108

54-
def create_wallet_transaction(amount_cents)
109+
def wallets_already_applied?
110+
return false unless invoice
111+
112+
WalletTransaction.exists?(invoice_id: invoice.id, wallet_id: wallets.map(&:id))
113+
end
114+
115+
def create_wallet_transaction(wallet, amount_cents)
55116
wallet_credit = WalletCredit.from_amount_cents(wallet:, amount_cents:)
56117

57118
result = WalletTransactions::CreateService.call!(
@@ -82,45 +143,12 @@ def with_optimistic_lock_retry(wallet, &block)
82143
end
83144
end
84145

85-
def already_applied?
86-
invoice&.wallet_transactions&.exists?
87-
end
88-
89-
def compute_amount
90-
if wallet.limited_to_billable_metrics? && billable_metric_limited_fees
91-
bm_limited_fees = billable_metric_limited_fees
92-
remaining_fees = invoice.fees - bm_limited_fees
93-
remaining_fees = remaining_fees.reject { |fee| fee.fee_type == "charge" }
94-
else
95-
bm_limited_fees = []
96-
remaining_fees = invoice.fees
97-
end
98-
99-
fee_type_limited_fees = if wallet.limited_fee_types?
100-
remaining_fees.filter { |fee| wallet.allowed_fee_types.include?(fee.fee_type) }
101-
elsif wallet.limited_to_billable_metrics? && billable_metric_limited_fees
102-
[]
103-
else
104-
remaining_fees
105-
end
106-
107-
if wallet.limited_fee_types? || wallet.limited_to_billable_metrics?
108-
[balance_cents, limited_fees_total(bm_limited_fees + fee_type_limited_fees)].min
109-
else
110-
[balance_cents, invoice.total_amount_cents].min
111-
end
112-
end
113-
114-
def billable_metric_limited_fees
115-
@billable_metric_limited_fees ||= invoice.fees
116-
.joins(charge: :billable_metric)
117-
.where(billable_metric: {id: wallet.wallet_targets.pluck(:billable_metric_id)})
118-
end
146+
def applicable_fee?(fee_key:, targets:, types:)
147+
target_match = targets.include?(fee_key)
148+
type_match = types.include?(fee_key.first)
149+
unrestricted_wallet = targets.empty? && types.empty?
119150

120-
def limited_fees_total(applicable_fees)
121-
applicable_fees.sum do |f|
122-
f.sub_total_excluding_taxes_precise_amount_cents + f.taxes_precise_amount_cents - f.precise_credit_notes_amount_cents
123-
end
151+
target_match || type_match || unrestricted_wallet
124152
end
125153
end
126154
end

app/services/customers/refresh_wallets_service.rb

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

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

77
def initialize(customer:, include_generating_invoices: false)
88
@customer = customer
@@ -27,11 +27,18 @@ def call
2727
}
2828
end
2929

30+
allocation_rules = Wallets::BuildAllocationRulesService.call!(customer:).allocation_rules
31+
3032
customer.wallets.active.find_each do |wallet|
31-
Wallets::Balance::RefreshOngoingUsageService.call!(wallet:, usage_amount_cents:)
33+
Wallets::Balance::RefreshOngoingUsageService.call!(
34+
wallet:,
35+
usage_amount_cents:,
36+
allocation_rules:
37+
)
3238
end
3339

3440
result.usage_amount_cents = usage_amount_cents
41+
result.allocation_rules = allocation_rules
3542
result.wallets = customer.wallets.active.reload
3643
result
3744
rescue BaseService::FailedResult => e

app/services/invoices/calculate_fees_service.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,8 @@ def should_create_charge_fees?(subscription)
292292
end
293293

294294
def wallets
295-
@wallets ||= customer.wallets.active.with_positive_balance
295+
@wallets ||= customer.wallets.active.includes(:wallet_targets)
296+
.with_positive_balance.in_application_order
296297
end
297298

298299
def should_create_credit_note_credit?

app/services/invoices/create_pay_in_advance_charge_service.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ def should_deliver_email?
119119
end
120120

121121
def wallets
122-
@wallets ||= customer.wallets.active.with_positive_balance
122+
@wallets ||= customer.wallets.active.includes(:wallet_targets)
123+
.with_positive_balance.in_application_order
123124
end
124125

125126
def should_create_applied_prepaid_credit?

app/services/invoices/progressive_billing_service.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,13 @@ def create_credit_note_credit
146146
end
147147

148148
def create_applied_prepaid_credit
149-
wallets = subscription.customer.wallets.active.with_positive_balance
149+
wallets = subscription.customer.wallets.active.includes(:wallet_targets)
150+
.with_positive_balance.in_application_order
150151

151152
return if wallets.none?
152153
return unless invoice.total_amount_cents.positive?
153154

154155
prepaid_credit_result = Credits::AppliedPrepaidCreditsService.call!(invoice:, wallets:)
155-
156156
invoice.total_amount_cents -= prepaid_credit_result.prepaid_credit_amount_cents
157157
end
158158
end

app/services/invoices/provider_taxes/pull_taxes_and_apply_service.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ def should_deliver_email?
8585
end
8686

8787
def wallets
88-
@wallets ||= customer.wallets.active.with_positive_balance
88+
@wallets ||= customer.wallets.active.includes(:wallet_targets)
89+
.with_positive_balance.in_application_order
8990
end
9091

9192
def should_create_credit_note_credit?

app/services/invoices/regenerate_from_voided_service.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,12 @@ def should_create_applied_prepaid_credit?
7979
end
8080

8181
def wallets
82-
@wallets ||= voided_invoice.customer.wallets.active.with_positive_balance
82+
@wallets ||= voided_invoice.customer.wallets.active.includes(:wallet_targets)
83+
.with_positive_balance.in_application_order
8384
end
8485

8586
def create_applied_prepaid_credit
8687
prepaid_credit_result = Credits::AppliedPrepaidCreditsService.call!(invoice: regenerated_invoice, wallets:)
87-
8888
refresh_amounts(credit_amount_cents: prepaid_credit_result.prepaid_credit_amount_cents)
8989
end
9090

app/services/wallets/balance/decrease_service.rb

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
module Wallets
44
module Balance
55
class DecreaseService < BaseService
6-
def initialize(wallet:, wallet_transaction:)
6+
def initialize(wallet:, wallet_transaction:, skip_refresh: false)
77
@wallet = wallet.reload
88
@wallet_transaction = wallet_transaction
9+
@skip_refresh = skip_refresh
910

1011
super
1112
end
@@ -23,7 +24,12 @@ def call
2324
last_consumed_credit_at: Time.current
2425
)
2526

26-
Customers::RefreshWalletsService.call(customer: wallet.customer, include_generating_invoices: true)
27+
unless skip_refresh
28+
Customers::RefreshWalletsService.call(
29+
customer: wallet.customer,
30+
include_generating_invoices: true
31+
)
32+
end
2733

2834
after_commit { SendWebhookJob.perform_later("wallet.updated", wallet) }
2935

@@ -33,7 +39,7 @@ def call
3339

3440
private
3541

36-
attr_reader :wallet, :wallet_transaction
42+
attr_reader :wallet, :wallet_transaction, :skip_refresh
3743
end
3844
end
3945
end

app/services/wallets/balance/refresh_ongoing_service.rb

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,18 +93,33 @@ def credits_ongoing_balance
9393
ongoing_balance_cents.to_f.fdiv(currency.subunit_to_unit).fdiv(wallet.rate_amount)
9494
end
9595

96-
def calculate_total_usage_with_limitation(usage_amount_cents)
97-
return usage_amount_cents.sum { |e| e[:total_usage_amount_cents] } unless wallet.limited_to_billable_metrics?
96+
def assign_wallet_per_fee(fees)
97+
fee_wallet = {}
98+
99+
allocation_rules = Wallets::BuildAllocationRulesService.call!(customer:).allocation_rules
100+
101+
fees.each do |fee|
102+
key = fee.id || fee.object_id
103+
104+
applicable_wallets = Wallets::FindApplicableOnFeesService
105+
.call!(allocation_rules:, fee:)
106+
.top_priority_wallet
107+
108+
fee_wallet[key] = applicable_wallets.presence
109+
end
110+
111+
fee_wallet
112+
end
98113

99-
# current usage fees are not persisted so we can't use join
114+
def calculate_total_usage_with_limitation(usage_amount_cents)
100115
all_fees = usage_amount_cents.flat_map { |usage| usage[:invoice].fees }
101-
charge_ids = Charge.where(id: all_fees.map(&:charge_id)).where(billable_metric_id: wallet.wallet_targets.pluck(:billable_metric_id)).pluck(:id)
116+
return 0 if all_fees.empty?
102117

103-
return usage_amount_cents.sum { |e| e[:total_usage_amount_cents] } if charge_ids.empty?
118+
wallets_applicable_on_fees = assign_wallet_per_fee(all_fees) # { fee_key => wallet_id }
104119

105-
all_fees
106-
.select { |f| charge_ids.include?(f.charge_id) }
107-
.sum { |f| f.amount_cents + f.taxes_amount_cents }
120+
all_fees.sum do |f|
121+
(wallets_applicable_on_fees[(f.id || f.object_id)] == wallet.id) ? (f.amount_cents + f.taxes_amount_cents) : 0
122+
end
108123
end
109124
end
110125
end

0 commit comments

Comments
 (0)