Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/controllers/api/v1/wallet_transactions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ def input_params
:paid_credits,
:granted_credits,
:voided_credits,
:voided_transaction_id,
:invoice_requires_successful_payment,
:name,
:purchase_order_number,
Expand Down
21 changes: 16 additions & 5 deletions app/services/wallet_transactions/create_from_params_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module WalletTransactions
class CreateFromParamsService < ::BaseService
MAX_WALLET_UPDATE_ATTEMPTS = 5

Result = BaseResult[:current_wallet, :wallet_transactions, :payment_method]
Result = BaseResult[:current_wallet, :wallet_transactions, :payment_method, :voided_wallet_transaction]

def initialize(organization:, params:)
@organization = organization
Expand Down Expand Up @@ -52,7 +52,7 @@ def call
wallet_transactions << transaction
end

if params[:voided_credits]
if params[:voided_credits] || params[:voided_transaction_id].present?
wallet_transactions << handle_voided_credits(wallet)
end

Expand Down Expand Up @@ -94,10 +94,21 @@ def name
end

def handle_voided_credits(wallet)
credit_amount = BigDecimal(params[:voided_credits]).floor(5)
wallet_credit = WalletCredit.new(wallet:, credit_amount:, invoiceable: false)
# Resolved and validated upstream by ValidateService; nil means a pool-wide void.
inbound_wallet_transaction = result.voided_wallet_transaction
void_params = params.to_h.symbolize_keys.slice(:metadata, :source, :priority).merge(name:)
WalletTransactions::VoidService.call!(wallet:, wallet_credit:, **void_params).wallet_transaction

if params[:voided_credits]
wallet_credit = WalletCredit.new(wallet:, credit_amount: BigDecimal(params[:voided_credits]).floor(5), invoiceable: false)
WalletTransactions::VoidService.call!(
wallet:, wallet_credit:, inbound_wallet_transaction:, **void_params
).wallet_transaction
else
# No amount given: void the grant's whole remaining, sized under the lock in VoidService.
WalletTransactions::VoidService.call!(
wallet:, inbound_wallet_transaction:, void_remaining: true, **void_params
).wallet_transaction
end
end

def handle_paid_credits(wallet:, credits_amount:, invoice_requires_successful_payment:)
Expand Down
19 changes: 19 additions & 0 deletions app/services/wallet_transactions/validate_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ def valid?
valid_paid_credits_amount? if args[:paid_credits]
valid_granted_credits_amount? if args[:granted_credits]
valid_voided_credits_amount? if args[:voided_credits] && result.current_wallet
valid_voided_transaction? if args[:voided_transaction_id].present? && result.current_wallet
valid_metadata? if args[:metadata]
valid_name? if args[:name]
valid_payment_method?
Expand Down Expand Up @@ -84,6 +85,24 @@ def valid_voided_credits_amount?
true
end

def valid_voided_transaction?
# Targeting a specific grant relies on the per-transaction ledger, which only traceable wallets keep.
unless result.current_wallet.traceable?
return add_error(field: :voided_transaction_id, error_code: "wallet_not_traceable")
end

transaction = result.current_wallet.wallet_transactions.inbound.find_by(id: args[:voided_transaction_id])

return add_error(field: :voided_transaction_id, error_code: "wallet_transaction_not_found") unless transaction

if transaction.remaining_amount_cents.to_i <= 0
return add_error(field: :voided_transaction_id, error_code: "no_remaining_amount")
end

result.voided_wallet_transaction = transaction
true
end

def valid_metadata?
validator = ::Validators::MetadataValidator.new(args[:metadata], {max_keys: MAX_METADATA_KEYS})
unless validator.valid?
Expand Down
53 changes: 32 additions & 21 deletions app/services/wallet_transactions/void_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ module WalletTransactions
class VoidService < BaseService
Result = BaseResult[:wallet_transaction]

def initialize(wallet:, wallet_credit:, inbound_wallet_transaction: nil, **transaction_params)
def initialize(wallet:, wallet_credit: nil, inbound_wallet_transaction: nil, void_remaining: false, **transaction_params)
@wallet = wallet
@wallet_credit = wallet_credit
@inbound_wallet_transaction = inbound_wallet_transaction
@void_remaining = void_remaining
@transaction_params = transaction_params.slice(
:source,
:metadata,
Expand All @@ -20,32 +21,37 @@ def initialize(wallet:, wallet_credit:, inbound_wallet_transaction: nil, **trans
end

def call
return result if wallet_credit.credit_amount.zero?
return result if !void_remaining && wallet_credit.credit_amount.zero?
return result unless valid?

ActiveRecord::Base.transaction do
Customers::LockService.call(customer:, scope: :prepaid_credit) do
wallet.reload
wallet_transaction = CreateService.call!(
wallet:,
wallet_credit:,
transaction_type: :outbound,
status: :settled,
settled_at: Time.current,
transaction_status: :voided,
billing_entity_id: inbound_wallet_transaction&.billing_entity_id,
**transaction_params
).wallet_transaction
# Size the whole-remaining void under the lock so concurrent consumption can't make it stale.
void_credit = void_remaining ? whole_remaining_credit : wallet_credit

if wallet.traceable?
TrackConsumptionService.call!(
outbound_wallet_transaction: wallet_transaction,
inbound_wallet_transaction_id: inbound_wallet_transaction&.id
)
end
unless void_credit.credit_amount.zero?
wallet_transaction = CreateService.call!(
wallet:,
wallet_credit: void_credit,
transaction_type: :outbound,
status: :settled,
settled_at: Time.current,
transaction_status: :voided,
billing_entity_id: inbound_wallet_transaction&.billing_entity_id,
**transaction_params
).wallet_transaction

if wallet.traceable?
TrackConsumptionService.call!(
outbound_wallet_transaction: wallet_transaction,
inbound_wallet_transaction_id: inbound_wallet_transaction&.id
)
end

Wallets::Balance::DecreaseService.new(wallet:, wallet_transaction:).call
result.wallet_transaction = wallet_transaction
Wallets::Balance::DecreaseService.new(wallet:, wallet_transaction:).call
result.wallet_transaction = wallet_transaction
end
end
end

Expand All @@ -54,12 +60,17 @@ def call

private

attr_reader :wallet, :wallet_credit, :inbound_wallet_transaction, :transaction_params
attr_reader :wallet, :wallet_credit, :inbound_wallet_transaction, :void_remaining, :transaction_params
delegate :customer, to: :wallet

def whole_remaining_credit
WalletCredit.from_amount_cents(wallet:, amount_cents: inbound_wallet_transaction.reload.remaining_amount_cents)
end

def valid?
return true unless wallet.traceable?
return true unless inbound_wallet_transaction
return true if void_remaining

if wallet_credit.amount_cents > inbound_wallet_transaction.remaining_amount_cents
result.single_validation_failure!(
Expand Down
114 changes: 114 additions & 0 deletions spec/requests/api/v1/wallet_transactions_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,120 @@
end
end

context "with a targeted voided transaction" do
let(:wallet) { create(:wallet, customer:, credits_balance: 20, balance_cents: 2000) }
let(:grant_a) do
create(:wallet_transaction, wallet:, transaction_type: :inbound, transaction_status: :granted,
status: :settled, amount: 5, credit_amount: 5, remaining_amount_cents: 500)
end
let(:grant_b) do
create(:wallet_transaction, wallet:, transaction_type: :inbound, transaction_status: :granted,
status: :settled, amount: 15, credit_amount: 15, remaining_amount_cents: 1500)
end

before do
grant_a
grant_b
end

# grant_a is created first at the same priority, so it is first in consumption order:
# a pool-wide void would drain it before grant_b. Targeting grant_b therefore proves
# the void was routed to the specified grant rather than falling back to FIFO.
context "without an amount" do
let(:params) { {wallet_id:, voided_transaction_id: grant_b.id} }

it "voids the targeted grant's whole remaining and leaves the earlier grant untouched" do
subject

expect(response).to have_http_status(:success)
expect(json[:wallet_transactions].count).to eq(1)
expect(json[:wallet_transactions].first).to include(
transaction_status: "voided",
credit_amount: "15.0"
)
expect(grant_b.reload.remaining_amount_cents).to eq(0)
expect(grant_a.reload.remaining_amount_cents).to eq(500)
expect(wallet.reload.credits_balance).to eq(5)
end
end

context "with an explicit amount" do
let(:params) { {wallet_id:, voided_transaction_id: grant_b.id, voided_credits: "2"} }

it "voids that amount from the targeted grant only" do
subject

expect(response).to have_http_status(:success)
expect(grant_b.reload.remaining_amount_cents).to eq(1300)
expect(grant_a.reload.remaining_amount_cents).to eq(500)
end
end

context "when the amount exceeds the targeted grant remaining" do
let(:params) { {wallet_id:, voided_transaction_id: grant_a.id, voided_credits: "10"} }

it "returns an error without touching any grant" do
subject

expect(response).to have_http_status(:unprocessable_content)
expect(json[:error_details][:amount_cents]).to eq(["exceeds_remaining_transaction_amount"])
expect(grant_a.reload.remaining_amount_cents).to eq(500)
end
end

context "when the targeted transaction belongs to another wallet" do
let(:other_grant) { create(:wallet_transaction, transaction_type: :inbound, remaining_amount_cents: 500) }
let(:params) { {wallet_id:, voided_transaction_id: other_grant.id} }

it "returns a not found error" do
subject

expect(response).to have_http_status(:unprocessable_content)
expect(json[:error_details][:voided_transaction_id]).to eq(["wallet_transaction_not_found"])
end
end

context "when the targeted grant has no remaining balance" do
let(:consumed_grant) do
create(:wallet_transaction, wallet:, transaction_type: :inbound, transaction_status: :granted,
status: :settled, amount: 5, credit_amount: 5, remaining_amount_cents: 0)
end
let(:params) { {wallet_id:, voided_transaction_id: consumed_grant.id} }

it "returns an error" do
subject

expect(response).to have_http_status(:unprocessable_content)
expect(json[:error_details][:voided_transaction_id]).to eq(["no_remaining_amount"])
end
end

context "when the wallet is not traceable" do
let(:wallet) { create(:wallet, customer:, credits_balance: 20, balance_cents: 2000, traceable: false) }
let(:params) { {wallet_id:, voided_transaction_id: grant_a.id} }

it "rejects targeting" do
subject

expect(response).to have_http_status(:unprocessable_content)
expect(json[:error_details][:voided_transaction_id]).to eq(["wallet_not_traceable"])
end
end

context "when voided_transaction_id is blank" do
# An SDK may serialize an unset field as "" rather than omitting it: still a pool-wide void.
let(:params) { {wallet_id:, voided_credits: "5", voided_transaction_id: ""} }

it "falls back to a pool-wide void" do
subject

expect(response).to have_http_status(:success)
expect(grant_a.reload.remaining_amount_cents).to eq(0)
expect(grant_b.reload.remaining_amount_cents).to eq(1500)
end
end
end

context "when metadata is present" do
let(:params) do
{
Expand Down
60 changes: 59 additions & 1 deletion spec/services/wallet_transactions/validate_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
RSpec.describe WalletTransactions::ValidateService do
subject(:validate_service) { described_class.new(result, **args) }

let(:result) { BaseResult[:current_wallet, :payment_method].new }
let(:result) { BaseResult[:current_wallet, :payment_method, :voided_wallet_transaction].new }
let(:membership) { create(:membership) }
let(:organization) { membership.organization }
let(:customer) { create(:customer, organization:) }
Expand Down Expand Up @@ -205,6 +205,64 @@
end
end

context "with voided_transaction_id" do
let(:args) { {wallet_id:, organization_id: organization.id, voided_transaction_id:} }
let(:voided_transaction_id) { wallet_transaction.id }
let(:wallet_transaction) do
create(:wallet_transaction, wallet:, transaction_type: :inbound, transaction_status: :granted,
status: :settled, amount: 5, credit_amount: 5, remaining_amount_cents: 500)
end

before { wallet_transaction }

it "is valid and memoizes the targeted transaction" do
expect(validate_service).to be_valid
expect(result.voided_wallet_transaction).to eq(wallet_transaction)
end

context "when the wallet is not traceable" do
let(:wallet) { create(:wallet, customer:, traceable: false) }

it "is invalid" do
expect(validate_service).not_to be_valid
expect(result.error.messages[:voided_transaction_id]).to eq(["wallet_not_traceable"])
end
end

context "when the transaction is not an inbound of the wallet" do
let(:voided_transaction_id) { create(:wallet_transaction, transaction_type: :inbound).id }

it "is invalid" do
expect(validate_service).not_to be_valid
expect(result.error.messages[:voided_transaction_id]).to eq(["wallet_transaction_not_found"])
end
end

context "when the targeted transaction is fully consumed" do
let(:wallet_transaction) do
create(:wallet_transaction, wallet:, transaction_type: :inbound, transaction_status: :granted,
status: :settled, amount: 5, credit_amount: 5, remaining_amount_cents: 0)
end

it "is invalid" do
expect(validate_service).not_to be_valid
expect(result.error.messages[:voided_transaction_id]).to eq(["no_remaining_amount"])
end
end

context "when the targeted transaction is unsettled" do
let(:wallet_transaction) do
create(:wallet_transaction, wallet:, transaction_type: :inbound, transaction_status: :purchased,
status: :pending, amount: 5, credit_amount: 5, remaining_amount_cents: nil)
end

it "is invalid" do
expect(validate_service).not_to be_valid
expect(result.error.messages[:voided_transaction_id]).to eq(["no_remaining_amount"])
end
end
end

context "with valid voided_credits but insufficient credits" do
let(:voided_credits) { "1.00" }

Expand Down
Loading
Loading