fix(wallet): enforce consistent withdrawal accounting
This commit is contained in:
212
backend/api/internal/logic/common/wallet_balance_test.go
Normal file
212
backend/api/internal/logic/common/wallet_balance_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
)
|
||||
|
||||
func TestSpendWalletBalanceClosesWithdrawableGap(t *testing.T) {
|
||||
wallet := models.WalletBasic{
|
||||
Entity: NewEntity(StatusEnable),
|
||||
Balance: 10_000,
|
||||
WithdrawalBalance: 10_000,
|
||||
}
|
||||
|
||||
if err := SpendWalletBalance(&wallet, 8_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.Balance != 2_000 || wallet.WithdrawalBalance != 2_000 {
|
||||
t.Fatalf("balances after expense = (%d, %d), want (2000, 2000)", wallet.Balance, wallet.WithdrawalBalance)
|
||||
}
|
||||
if err := ReserveWalletWithdrawal(&wallet, 10_000); err != ErrWalletBalance {
|
||||
t.Fatalf("withdrawal after expense error = %v, want %v", err, ErrWalletBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpendWalletBalanceConsumesNonWithdrawableBalanceFirst(t *testing.T) {
|
||||
wallet := models.WalletBasic{
|
||||
Entity: NewEntity(StatusEnable),
|
||||
Balance: 15_000,
|
||||
WithdrawalBalance: 10_000,
|
||||
}
|
||||
|
||||
if err := SpendWalletBalance(&wallet, 4_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.Balance != 11_000 || wallet.WithdrawalBalance != 10_000 {
|
||||
t.Fatalf("balances after first expense = (%d, %d), want (11000, 10000)", wallet.Balance, wallet.WithdrawalBalance)
|
||||
}
|
||||
if err := SpendWalletBalance(&wallet, 2_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.Balance != 9_000 || wallet.WithdrawalBalance != 9_000 {
|
||||
t.Fatalf("balances after second expense = (%d, %d), want (9000, 9000)", wallet.Balance, wallet.WithdrawalBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawalReserveAndRejectAreExactInverse(t *testing.T) {
|
||||
wallet := models.WalletBasic{
|
||||
Entity: NewEntity(StatusEnable),
|
||||
Balance: 10_000,
|
||||
WithdrawalBalance: 10_000,
|
||||
}
|
||||
|
||||
if err := ReserveWalletWithdrawal(&wallet, 10_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.Balance != 0 || wallet.WithdrawalBalance != 0 {
|
||||
t.Fatalf("reserved balances = (%d, %d), want (0, 0)", wallet.Balance, wallet.WithdrawalBalance)
|
||||
}
|
||||
if err := ReleaseWalletWithdrawal(&wallet, 10_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.Balance != 10_000 || wallet.WithdrawalBalance != 10_000 {
|
||||
t.Fatalf("released balances = (%d, %d), want (10000, 10000)", wallet.Balance, wallet.WithdrawalBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyWithdrawalReleaseCannotExceedRemainingBalance(t *testing.T) {
|
||||
wallet := models.WalletBasic{
|
||||
Entity: NewEntity(StatusEnable),
|
||||
Balance: 2_000,
|
||||
WithdrawalBalance: 2_000,
|
||||
}
|
||||
|
||||
if err := ReleaseLegacyWithdrawalBalance(&wallet, 8_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.Balance != 2_000 || wallet.WithdrawalBalance != 2_000 {
|
||||
t.Fatalf("legacy released balances = (%d, %d), want (2000, 2000)", wallet.Balance, wallet.WithdrawalBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrozenWalletRejectsNewDebits(t *testing.T) {
|
||||
wallet := models.WalletBasic{
|
||||
Entity: NewEntity(StatusFrozen),
|
||||
Balance: 10_000,
|
||||
WithdrawalBalance: 10_000,
|
||||
}
|
||||
|
||||
if err := SpendWalletBalance(&wallet, 1); err != ErrWalletUnavailable {
|
||||
t.Fatalf("expense error = %v, want %v", err, ErrWalletUnavailable)
|
||||
}
|
||||
if err := ReserveWalletWithdrawal(&wallet, 1); err != ErrWalletUnavailable {
|
||||
t.Fatalf("withdrawal error = %v, want %v", err, ErrWalletUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawalReleaseRejectsOverflow(t *testing.T) {
|
||||
wallet := models.WalletBasic{
|
||||
Entity: NewEntity(StatusEnable),
|
||||
Balance: math.MaxInt64,
|
||||
WithdrawalBalance: math.MaxInt64,
|
||||
}
|
||||
|
||||
if err := ReleaseWalletWithdrawal(&wallet, 1); err != ErrWalletOverflow {
|
||||
t.Fatalf("release error = %v, want %v", err, ErrWalletOverflow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithdrawalRejectionRestoresBothBalances(t *testing.T) {
|
||||
wallet := models.WalletBasic{
|
||||
Entity: NewEntity(StatusEnable),
|
||||
OwnerType: "delivery",
|
||||
Balance: 2_000,
|
||||
WithdrawalBalance: 2_000,
|
||||
}
|
||||
application := models.WalletApplyCash{Amount: 8_000, BalanceReserved: true}
|
||||
|
||||
released, err := ReleaseWithdrawalApplication(&wallet, application)
|
||||
if err != nil || !released {
|
||||
t.Fatalf("release = (%v, %v), want (true, nil)", released, err)
|
||||
}
|
||||
if wallet.Balance != 10_000 || wallet.WithdrawalBalance != 10_000 {
|
||||
t.Fatalf("released balances = (%d, %d), want (10000, 10000)", wallet.Balance, wallet.WithdrawalBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyOrganizationWithdrawalRejectionDoesNotMintBalance(t *testing.T) {
|
||||
wallet := models.WalletBasic{
|
||||
Entity: NewEntity(StatusEnable),
|
||||
OwnerType: "delivery",
|
||||
Balance: 10_000,
|
||||
WithdrawalBalance: 10_000,
|
||||
}
|
||||
application := models.WalletApplyCash{Amount: 8_000}
|
||||
|
||||
released, err := ReleaseWithdrawalApplication(&wallet, application)
|
||||
if err != nil || released {
|
||||
t.Fatalf("release = (%v, %v), want (false, nil)", released, err)
|
||||
}
|
||||
if wallet.Balance != 10_000 || wallet.WithdrawalBalance != 10_000 {
|
||||
t.Fatalf("legacy organization balances changed to (%d, %d)", wallet.Balance, wallet.WithdrawalBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyWithdrawalCompletionDebitsMissingBalances(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ownerType string
|
||||
withdrawalBalance int64
|
||||
wantBalance int64
|
||||
wantWithdrawal int64
|
||||
}{
|
||||
{
|
||||
name: "client already reserved withdrawable balance",
|
||||
ownerType: "user",
|
||||
withdrawalBalance: 2_000,
|
||||
wantBalance: 2_000,
|
||||
wantWithdrawal: 2_000,
|
||||
},
|
||||
{
|
||||
name: "organization reserved neither balance",
|
||||
ownerType: "delivery",
|
||||
withdrawalBalance: 10_000,
|
||||
wantBalance: 2_000,
|
||||
wantWithdrawal: 2_000,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
wallet := models.WalletBasic{
|
||||
Entity: NewEntity(StatusEnable),
|
||||
OwnerType: test.ownerType,
|
||||
Balance: 10_000,
|
||||
WithdrawalBalance: test.withdrawalBalance,
|
||||
}
|
||||
changed, err := SettleLegacyWithdrawal(&wallet, models.WalletApplyCash{Amount: 8_000})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("settle = (%v, %v), want (true, nil)", changed, err)
|
||||
}
|
||||
if wallet.Balance != test.wantBalance || wallet.WithdrawalBalance != test.wantWithdrawal {
|
||||
t.Fatalf(
|
||||
"settled balances = (%d, %d), want (%d, %d)",
|
||||
wallet.Balance,
|
||||
wallet.WithdrawalBalance,
|
||||
test.wantBalance,
|
||||
test.wantWithdrawal,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReservedWithdrawalCompletionDoesNotDebitAgain(t *testing.T) {
|
||||
wallet := models.WalletBasic{
|
||||
Entity: NewEntity(StatusEnable),
|
||||
OwnerType: "user",
|
||||
Balance: 2_000,
|
||||
WithdrawalBalance: 2_000,
|
||||
}
|
||||
application := models.WalletApplyCash{Amount: 8_000, BalanceReserved: true}
|
||||
|
||||
changed, err := SettleLegacyWithdrawal(&wallet, application)
|
||||
if err != nil || changed {
|
||||
t.Fatalf("settle = (%v, %v), want (false, nil)", changed, err)
|
||||
}
|
||||
if wallet.Balance != 2_000 || wallet.WithdrawalBalance != 2_000 {
|
||||
t.Fatalf("reserved completion changed balances to (%d, %d)", wallet.Balance, wallet.WithdrawalBalance)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user