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 details []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 updated store.PaymentAccountCreation updatedID int64 } 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 *paymentAccountRepository) UpdatePaymentAccount(_ context.Context, id int64, input store.PaymentAccountCreation) (*store.PaymentAccount, error) { f.updatedID = id f.updated = input return &store.PaymentAccount{ID: id, AccountType: input.AccountType, BankName: input.BankName}, 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) PaymentAccountDetails(context.Context, int64, int64) ([]store.PaymentAccount, error) { return f.details, nil } func (f *fakeRepository) CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) { return nil, errors.New("not implemented") } func (f *fakeRepository) UpdatePaymentAccount(context.Context, int64, 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 TestQuoteDefaultsToEnabledWithoutWithdrawalSwitch(t *testing.T) { service := &Service{repository: &fakeRepository{config: json.RawMessage(`{}`)}, ready: true, now: time.Now} if err := service.ensureWithdrawalEnabled(context.Background(), 0); err != nil { t.Fatalf("missing switch should default to enabled, got %v", err) } } func TestQuoteHonorsExplicitDisabledWithdrawalSwitch(t *testing.T) { service := &Service{repository: &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":false}}`)}, 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 explicitly disabled withdrawal, got %v", err) } } func TestNormalizePaymentAccount(t *testing.T) { input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三", AccountNo: "6222 0000-0000 1234", DigitalRMBCode: "1234 5678", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9} if err := normalizePaymentAccount(&input); err != nil { t.Fatal(err) } if input.AccountNo != "6222000000001234" || input.DigitalRMBCode != "12345678" || maskAccount(input.AccountNo) != "6222********1234" { t.Fatalf("unexpected normalized input: %#v", input) } } func TestNormalizePaymentAccountAllowsEmptyDigitalRMBCode(t *testing.T) { input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三", 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 TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) { repository := &fakeRepository{} service := New(repository, &redis.Client{}, config.AuthConfig{ FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters", }) holder, _ := service.encrypt("张三") account, _ := service.encrypt("6222000000001234") digital, _ := service.encrypt("123456") repository.details = []store.PaymentAccount{{ ID: 7, BankName: "中国工商银行", HolderCipher: holder, AccountNoCipher: account, DigitalRMBCodeCipher: digital, WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, VerifyStatus: 2, }} items, err := service.PaymentAccountDetails(context.Background(), 1, 2) if err != nil { t.Fatal(err) } if len(items) != 1 || items[0].HolderName != "张三" || items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" || items[0].WechatQRURL != "/wechat.png" || items[0].AlipayQRURL != "/alipay.png" || items[0].WechatQRAttachmentID != 8 || items[0].AlipayQRAttachmentID != 9 { t.Fatalf("unexpected account details: %#v", items) } } func TestPaymentAccountDetailsDecryptsWithLegacyMasterKey(t *testing.T) { repository := &fakeRepository{} legacyMasterKey := "legacy-finance-master-key-with-at-least-32-characters" legacyService := New(repository, &redis.Client{}, config.AuthConfig{FinanceMasterKey: legacyMasterKey}) holder, _ := legacyService.encrypt("张三") account, _ := legacyService.encrypt("6222000000001234") digital, _ := legacyService.encrypt("123456") repository.details = []store.PaymentAccount{{ ID: 7, BankName: "中国工商银行", HolderCipher: holder, AccountNoCipher: account, DigitalRMBCodeCipher: digital, WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, }} service := New(repository, &redis.Client{}, config.AuthConfig{ FinanceMasterKey: "current-finance-master-key-with-at-least-32-characters", FinanceLegacyMasterKeys: []string{legacyMasterKey}, }) items, err := service.PaymentAccountDetails(context.Background(), 1, 2) if err != nil { t.Fatal(err) } if len(items) != 1 || items[0].Masked || items[0].HolderName != "张三" || items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" { t.Fatalf("unexpected legacy account details: %#v", items) } } func TestUpdatePaymentAccountEncryptsEditedFields(t *testing.T) { repository := &paymentAccountRepository{} service := New(repository, &redis.Client{}, config.AuthConfig{ FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters", }) result, err := service.UpdatePaymentAccount(context.Background(), 1, 2, PaymentAccountInput{ ID: 7, BankName: "中国工商银行", HolderName: "李四", AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, }) if err != nil { t.Fatal(err) } if result.ID != 7 || repository.updatedID != 7 || repository.updated.TenantID != 1 || repository.updated.MemberID != 2 || repository.updated.WechatQRAttachmentID != 8 || repository.updated.AlipayQRAttachmentID != 9 || repository.updated.DigitalRMBCodeCipher != "" { t.Fatalf("unexpected updated account: result=%#v data=%#v", result, repository.updated) } if holder, err := service.decrypt(repository.updated.HolderCipher); err != nil || holder != "李四" { t.Fatalf("edited holder was not encrypted correctly: %q, %v", holder, err) } } func TestPaymentAccountDetailsFallsBackToMasksWhenHistoricalKeyIsUnavailable(t *testing.T) { repository := &fakeRepository{details: []store.PaymentAccount{{ ID: 8, BankName: "中国银行", HolderCipher: "legacy-holder", HolderMask: "张*", IdentityNoCipher: "legacy-id", IdentityNoMask: "1101**********123X", AccountNoCipher: "legacy-account", AccountNoMask: "6222********1234", DigitalRMBCodeCipher: "legacy-rmb", DigitalRMBCodeMask: "12****78", WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png", VerifyStatus: 2, }}} service := New(repository, &redis.Client{}, config.AuthConfig{ FinanceMasterKey: "different-finance-master-key-with-32-characters", }) items, err := service.PaymentAccountDetails(context.Background(), 1, 2) if err != nil { t.Fatal(err) } if len(items) != 1 || !items[0].Masked || items[0].HolderName != "张*" || items[0].AccountNo != "6222********1234" || items[0].DigitalRMBCode != "12****78" { t.Fatalf("unexpected masked fallback: %#v", items) } } 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: "张三", 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) } if repository.created.IdentityNoCipher != "" || repository.created.IdentityNoHash != "" || repository.created.IdentityNoMask != "" { t.Fatalf("identity number must no longer be collected: %#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") } }