278 lines
12 KiB
Go
278 lines
12 KiB
Go
package finance
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.118im.com/im_team/olons_server/internal/config"
|
|
"git.118im.com/im_team/olons_server/internal/store"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
type fakeRepository struct {
|
|
config json.RawMessage
|
|
account *store.PaymentAccount
|
|
channel *store.WithdrawalChannel
|
|
wallet *store.WithdrawalWallet
|
|
orders []store.WithdrawalOrder
|
|
rechargeChannels []store.RechargeChannel
|
|
rechargeOrders []store.RechargeOrder
|
|
ledgers []store.WalletLedger
|
|
createdRecharge store.RechargeCreation
|
|
}
|
|
|
|
type paymentAccountRepository struct {
|
|
fakeRepository
|
|
created store.PaymentAccountCreation
|
|
}
|
|
|
|
func (f *paymentAccountRepository) CreatePaymentAccount(_ context.Context, input store.PaymentAccountCreation) (*store.PaymentAccount, error) {
|
|
f.created = input
|
|
return &store.PaymentAccount{ID: 1, AccountType: input.AccountType}, nil
|
|
}
|
|
|
|
func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
|
|
return f.config, nil
|
|
}
|
|
func (f *fakeRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) {
|
|
return nil, errors.New("not implemented")
|
|
}
|
|
func (f *fakeRepository) PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeRepository) CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) {
|
|
return nil, errors.New("not implemented")
|
|
}
|
|
func (f *fakeRepository) PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error) {
|
|
return f.account, nil
|
|
}
|
|
func (f *fakeRepository) ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error) {
|
|
return f.channel, nil
|
|
}
|
|
func (f *fakeRepository) WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error) {
|
|
return f.wallet, nil
|
|
}
|
|
func (f *fakeRepository) CreateWithdrawal(context.Context, store.WithdrawalCreation) (*store.WithdrawalOrder, error) {
|
|
return nil, errors.New("not implemented")
|
|
}
|
|
func (f *fakeRepository) WithdrawalOrders(context.Context, int64, int64, int, int, int) (*store.WithdrawalPage, error) {
|
|
return &store.WithdrawalPage{Items: f.orders, Total: int64(len(f.orders))}, nil
|
|
}
|
|
func (f *fakeRepository) WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, error) {
|
|
return nil, errors.New("not implemented")
|
|
}
|
|
func (f *fakeRepository) RechargeChannels(context.Context, int64) ([]store.RechargeChannel, error) {
|
|
return f.rechargeChannels, nil
|
|
}
|
|
func (f *fakeRepository) CreateRecharge(_ context.Context, input store.RechargeCreation) (*store.RechargeOrder, error) {
|
|
f.createdRecharge = input
|
|
return &store.RechargeOrder{ID: 8, RechargeNo: input.RechargeNo, ChannelID: input.ChannelID,
|
|
ChannelName: "人工审核", RequestAmount: input.RequestAmount, Currency: input.Currency,
|
|
Status: 2, Remark: input.Remark}, nil
|
|
}
|
|
func (f *fakeRepository) RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error) {
|
|
return &store.RechargePage{Items: f.rechargeOrders, Total: int64(len(f.rechargeOrders))}, nil
|
|
}
|
|
func (f *fakeRepository) RechargeOrder(context.Context, int64, int64, int64) (*store.RechargeOrder, error) {
|
|
return nil, errors.New("not implemented")
|
|
}
|
|
func (f *fakeRepository) WalletLedgers(context.Context, int64, int64, string, int, int, int) (*store.WalletLedgerPage, error) {
|
|
return &store.WalletLedgerPage{Items: f.ledgers, Total: int64(len(f.ledgers))}, nil
|
|
}
|
|
|
|
func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) {
|
|
repository := &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":true}}`),
|
|
account: &store.PaymentAccount{ID: 3, AccountType: 1, AccountNoMask: "6222********1234", VerifyStatus: 2},
|
|
channel: &store.WithdrawalChannel{ID: 1, Code: "bank", Name: "银行卡", ChannelType: 3,
|
|
MinAmount: 1000, MaxAmount: 100000, FeeType: 3, FeeFixedAmount: 100, FeeRate: 25},
|
|
wallet: &store.WithdrawalWallet{AvailableAmount: 80000, AccountStatus: 1, DailyLimit: 3, TodayCount: 1}}
|
|
service := &Service{repository: repository, ready: true, now: func() time.Time {
|
|
return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)
|
|
}}
|
|
|
|
result, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 3, Amount: 50000, Currency: "cny"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.FeeAmount != 225 || result.ActualAmount != 49775 || result.MaximumAmount != 80000 ||
|
|
result.QuoteExpiresAt != "2026-10-09T02:05:00Z" {
|
|
t.Fatalf("unexpected quote: %#v", result)
|
|
}
|
|
}
|
|
|
|
func TestQuoteFailsClosed(t *testing.T) {
|
|
service := &Service{repository: &fakeRepository{config: json.RawMessage(`{}`)}, ready: true, now: time.Now}
|
|
_, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 1, Amount: 100, Currency: "CNY"})
|
|
if !errors.Is(err, store.ErrWithdrawalDisabled) {
|
|
t.Fatalf("expected disabled error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNormalizePaymentAccount(t *testing.T) {
|
|
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
|
|
IdentityNo: "11010119900101123X", AccountNo: "6222 0000-0000 1234",
|
|
DigitalRMBCode: "1234 5678",
|
|
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
|
|
if err := normalizePaymentAccount(&input); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if input.IdentityNo != "11010119900101123X" || input.AccountNo != "6222000000001234" ||
|
|
input.DigitalRMBCode != "12345678" ||
|
|
maskAccount(input.AccountNo) != "6222********1234" || maskIdentityNo(input.IdentityNo) != "1101**********123X" {
|
|
t.Fatalf("unexpected normalized input: %#v", input)
|
|
}
|
|
}
|
|
|
|
func TestNormalizePaymentAccountAllowsEmptyDigitalRMBCode(t *testing.T) {
|
|
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
|
|
IdentityNo: "11010119900101123X", AccountNo: "6222000000001234",
|
|
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
|
|
if err := normalizePaymentAccount(&input); err != nil {
|
|
t.Fatalf("empty digital RMB code should be optional: %v", err)
|
|
}
|
|
input.DigitalRMBCode = "1234"
|
|
if err := normalizePaymentAccount(&input); !errors.Is(err, ErrAccountFieldsInvalid) {
|
|
t.Fatalf("short non-empty digital RMB code should be rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreatePaymentAccountDoesNotStoreEmptyDigitalRMBCode(t *testing.T) {
|
|
repository := &paymentAccountRepository{}
|
|
service := New(repository, &redis.Client{}, config.AuthConfig{
|
|
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
|
|
})
|
|
_, err := service.CreatePaymentAccount(context.Background(), 0, 1, PaymentAccountInput{
|
|
BankName: "中国工商银行", HolderName: "张三", IdentityNo: "11010119900101123X",
|
|
AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if repository.created.DigitalRMBCodeCipher != "" || repository.created.DigitalRMBCodeHash != "" || repository.created.DigitalRMBCodeMask != "" {
|
|
t.Fatalf("empty digital RMB code must not be encrypted, hashed or masked: %#v", repository.created)
|
|
}
|
|
}
|
|
|
|
func TestMaskAccountShortValueDoesNotLeak(t *testing.T) {
|
|
if got := maskAccount("abcde"); got != "a***e" {
|
|
t.Fatalf("unexpected mask %q", got)
|
|
}
|
|
}
|
|
|
|
func TestWithdrawalStatusValidation(t *testing.T) {
|
|
service := &Service{repository: &fakeRepository{}, ready: true, now: time.Now}
|
|
_, err := service.WithdrawalOrders(context.Background(), 0, 1, 1, 20, "bad")
|
|
if !errors.Is(err, ErrStatusInvalid) {
|
|
t.Fatalf("expected status error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateRechargeValidatesChannelAndMapsOrder(t *testing.T) {
|
|
repository := &fakeRepository{rechargeChannels: []store.RechargeChannel{{
|
|
ID: 4, Code: "manual", Name: "人工审核", MinAmount: 100, MaxAmount: 100000,
|
|
}}}
|
|
service := New(repository, nil, config.AuthConfig{})
|
|
result, err := service.CreateRecharge(context.Background(), 0, 9, CreateRechargeInput{
|
|
ChannelID: 4, Amount: 50000, Currency: "cny", Remark: "测试",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Status != "reviewing" || result.RequestAmount != 50000 ||
|
|
repository.createdRecharge.MemberID != 9 || repository.createdRecharge.Currency != "CNY" {
|
|
t.Fatalf("unexpected recharge: %#v, creation: %#v", result, repository.createdRecharge)
|
|
}
|
|
}
|
|
|
|
func TestRechargeOrdersMapsStatusAndPagination(t *testing.T) {
|
|
repository := &fakeRepository{rechargeOrders: []store.RechargeOrder{{
|
|
ID: 3, RechargeNo: "RC3", RequestAmount: 20000, Currency: "CNY", Status: 3,
|
|
}}}
|
|
service := &Service{repository: repository, ready: true, now: time.Now}
|
|
result, err := service.RechargeOrders(context.Background(), 0, 9, 1, 20, "succeeded")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Total != 1 || len(result.Items) != 1 || result.Items[0].Status != "succeeded" || result.HasNext {
|
|
t.Fatalf("unexpected recharge page: %#v", result)
|
|
}
|
|
if _, err = service.RechargeOrders(context.Background(), 0, 9, 1, 20, "invalid"); !errors.Is(err, ErrRechargeStatusInvalid) {
|
|
t.Fatalf("expected recharge status error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFinanceOrderListsDoNotRequireEncryptionKeys(t *testing.T) {
|
|
repository := &fakeRepository{
|
|
orders: []store.WithdrawalOrder{{ID: 2, WithdrawalNo: "WD2", Status: 1}},
|
|
rechargeOrders: []store.RechargeOrder{{ID: 3, RechargeNo: "RC3", Status: 1}},
|
|
}
|
|
service := New(repository, nil, config.AuthConfig{})
|
|
|
|
withdrawals, err := service.WithdrawalOrders(context.Background(), 0, 9, 1, 20, "all")
|
|
if err != nil || len(withdrawals.Items) != 1 {
|
|
t.Fatalf("withdrawal records should remain readable without encryption keys: %#v, %v", withdrawals, err)
|
|
}
|
|
recharges, err := service.RechargeOrders(context.Background(), 0, 9, 1, 20, "all")
|
|
if err != nil || len(recharges.Items) != 1 {
|
|
t.Fatalf("recharge records should remain readable without encryption keys: %#v, %v", recharges, err)
|
|
}
|
|
if _, err = service.PaymentAccounts(context.Background(), 0, 9); err != nil {
|
|
t.Fatalf("masked payment accounts should remain readable without encryption keys, got %v", err)
|
|
}
|
|
if _, err = service.CreatePaymentAccount(context.Background(), 0, 9, PaymentAccountInput{}); !errors.Is(err, ErrUnavailable) {
|
|
t.Fatalf("sensitive finance operations must remain unavailable without encryption keys, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWalletLedgersDoNotRequireEncryptionKeys(t *testing.T) {
|
|
service := New(&fakeRepository{ledgers: []store.WalletLedger{{
|
|
ID: 7, LedgerNo: "WL7", Direction: 1, Amount: 947, Remark: "下级交易返佣",
|
|
}}}, nil, config.AuthConfig{})
|
|
result, err := service.WalletLedgers(context.Background(), 0, 9, 1, 20, "", "all")
|
|
if err != nil || result.Total != 1 || len(result.Items) != 1 ||
|
|
result.Items[0].Direction != "income" || result.Items[0].Amount != 947 {
|
|
t.Fatalf("wallet ledgers should remain readable without encryption keys: %#v, %v", result, err)
|
|
}
|
|
if _, err = service.WalletLedgers(context.Background(), 0, 9, 1, 20, "", "sideways"); !errors.Is(err, ErrLedgerFilterInvalid) {
|
|
t.Fatalf("expected invalid ledger direction, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeterministicNumberIsStableAndScoped(t *testing.T) {
|
|
first := deterministicNumber("WD", 0, 1, "request-key")
|
|
if first != deterministicNumber("WD", 0, 1, "request-key") || first == deterministicNumber("WD", 0, 2, "request-key") {
|
|
t.Fatal("deterministic number is not stable or member scoped")
|
|
}
|
|
}
|
|
|
|
func TestEncryptionRoundTrip(t *testing.T) {
|
|
service := New(&fakeRepository{}, nil, config.AuthConfig{
|
|
DataEncryptionKey: "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
|
|
LookupHMACKey: "ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=",
|
|
})
|
|
ciphertext, err := service.encrypt("6222000000001234")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plain, err := service.decrypt(ciphertext)
|
|
if err != nil || plain != "6222000000001234" {
|
|
t.Fatalf("unexpected round trip: %q, %v", plain, err)
|
|
}
|
|
}
|
|
|
|
func TestMasterKeyDerivesIndependentFinanceKeys(t *testing.T) {
|
|
service := New(&fakeRepository{}, nil, config.AuthConfig{
|
|
FinanceMasterKey: "production-master-key-with-at-least-32-characters",
|
|
})
|
|
if service.aead == nil || len(service.lookupKey) != 32 {
|
|
t.Fatal("expected the finance keys to be derived from the master key")
|
|
}
|
|
if string(deriveKey("production-master-key-with-at-least-32-characters", "data-encryption")) ==
|
|
string(deriveKey("production-master-key-with-at-least-32-characters", "lookup-hmac")) {
|
|
t.Fatal("derived keys must be isolated by purpose")
|
|
}
|
|
}
|