651 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package financewithdrawal
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/integration/withdrawalsource"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
activityv1 "hyapp.local/api/proto/activity/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestOperationsApprovalMovesToFinanceWithoutWalletOrUserNotice(t *testing.T) {
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
expectWithdrawalMoneyAccess(mock, 8, "lalu")
expectWithdrawalReviewLock(mock, model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending)
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
dto, err := svc.ApproveOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
UserID: 8, Username: "operations", Permissions: []string{permissionAuditOperationsWithdrawals},
}, 77, "资料无误", "request-ops-approve")
if err != nil {
t.Fatalf("approve operations withdrawal failed: %v", err)
}
if dto.Status != model.WithdrawalApplicationStatusPending || dto.OperationsStatus != model.WithdrawalOperationsStatusApproved || dto.OperationsReviewerName != "operations" {
t.Fatalf("operations approval state mismatch: %+v", dto)
}
if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 {
t.Fatalf("operations approval must not touch wallet or send final notice: wallet=%+v notice_calls=%d", wallet, activity.calls)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestValidateExternalWithdrawalBalanceBeforeUsesSubmissionSnapshot(t *testing.T) {
t.Parallel()
if err := validateExternalWithdrawalBalanceBefore("84.50", "84.00"); err != nil {
t.Fatalf("valid balance snapshot rejected: %v", err)
}
if err := validateExternalWithdrawalBalanceBefore("", "84.00"); err != nil {
t.Fatalf("legacy application without snapshot must remain compatible: %v", err)
}
if err := validateExternalWithdrawalBalanceBefore("83.99", "84.00"); err == nil {
t.Fatal("balance below withdrawal amount must be rejected")
}
}
func TestValidateExternalWithdrawalPayoutSnapshotUsesPersistedNetAmount(t *testing.T) {
t.Parallel()
minor := func(value int64) *int64 { return &value }
// ChatApp3 对 20% 手续费向上取整84 * 20% = 16.8,持久化费用为 17净打款为 67。
// Admin 只校验这三个申请时事实的金额恒等式,不复制上游取整策略。
valid := externalWithdrawalApplicationInput{
SourceSystem: "chatapp3", WithdrawAmount: "84", WithdrawAmountMinor: 8400,
ServiceChargeRatio: "20", ServiceCharge: "17", ActualAmount: "67", ActualAmountMinor: minor(6700),
}
if err := validateExternalWithdrawalPayoutSnapshot(valid); err != nil {
t.Fatalf("persisted ChatApp3 payout snapshot rejected: %v", err)
}
onePercent := valid
onePercent.WithdrawAmount = "100.00"
onePercent.WithdrawAmountMinor = 10000
onePercent.ServiceChargeRatio = "1"
onePercent.ServiceCharge = "1.00"
onePercent.ActualAmount = "99.00"
onePercent.ActualAmountMinor = minor(9900)
if err := validateExternalWithdrawalPayoutSnapshot(onePercent); err != nil {
t.Fatalf("percentage-point value 1 must mean 1%%, not a fraction: %v", err)
}
tests := []struct {
name string
input externalWithdrawalApplicationInput
}{
{name: "chatapp snapshot required", input: externalWithdrawalApplicationInput{SourceSystem: "chatapp3", WithdrawAmount: "84", WithdrawAmountMinor: 8400}},
{name: "partial snapshot", input: externalWithdrawalApplicationInput{SourceSystem: "likei", WithdrawAmount: "84", WithdrawAmountMinor: 8400, ServiceChargeRatio: "20"}},
{name: "actual minor mismatch", input: func() externalWithdrawalApplicationInput {
item := valid
item.ActualAmountMinor = minor(6699)
return item
}()},
{name: "gross decimal mismatch", input: func() externalWithdrawalApplicationInput { item := valid; item.ServiceCharge = "16"; return item }()},
{name: "gross minor mismatch", input: func() externalWithdrawalApplicationInput { item := valid; item.WithdrawAmountMinor = 8401; return item }()},
{name: "ratio over range", input: func() externalWithdrawalApplicationInput {
item := valid
item.ServiceChargeRatio = "100.0001"
return item
}()},
{name: "ratio scale overflow", input: func() externalWithdrawalApplicationInput {
item := valid
item.ServiceChargeRatio = "1.00000"
return item
}()},
{name: "amount scale overflow", input: func() externalWithdrawalApplicationInput { item := valid; item.ActualAmount = "67.000"; return item }()},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := validateExternalWithdrawalPayoutSnapshot(tt.input); err == nil {
t.Fatal("invalid external payout snapshot must be rejected")
}
})
}
if err := validateExternalWithdrawalPayoutSnapshot(externalWithdrawalApplicationInput{SourceSystem: "likei", WithdrawAmount: "84", WithdrawAmountMinor: 8400}); err != nil {
t.Fatalf("source without the optional payout snapshot must remain compatible: %v", err)
}
}
func TestWithdrawalReviewPendingMarkdownIncludesImmutablePayoutBreakdown(t *testing.T) {
t.Parallel()
ratio, charge, actual := "20.0000", "17.00", "67.00"
_, markdown := withdrawalReviewPendingMarkdown(model.UserWithdrawalApplication{
ID: 9, AppCode: "yumi", SourceSystem: "chatapp3", SourceApplicationID: "88", UserID: "10001",
WithdrawAmount: "84.00", ServiceChargeRatio: &ratio, ServiceCharge: &charge, ActualAmount: &actual,
}, repository.WithdrawalApplicationReviewStageFinance)
for _, want := range []string{"- 申请金额:$ 84.00", "- 手续费:$ 17.00(费率 20.0000%", "- 实际打款金额:$ 67.00"} {
if !strings.Contains(markdown, want) {
t.Fatalf("withdrawal review markdown missing %q:\n%s", want, markdown)
}
}
}
func TestExternalFinanceRetryAfterFinalizeFailureUsesFirstClaimSnapshot(t *testing.T) {
svc, mock, _, _, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
callbacks := make(chan withdrawalsource.DecisionRequest, 2)
callbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Header.Get("Authorization") != "Bearer callback-token" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var payload withdrawalsource.DecisionRequest
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
callbacks <- payload
w.WriteHeader(http.StatusNoContent)
}))
defer callbackServer.Close()
svc.sources = withdrawalsource.New([]config.WithdrawalSourceConfig{{
Enabled: true, AppCode: "yumi", SourceSystem: "chatapp3", SubmitToken: "submit-token",
CallbackURL: callbackServer.URL, CallbackToken: "callback-token", RequestTimeout: time.Second,
}})
commandID := "salary-withdrawal:77:finance"
firstImage := "https://example.com/first-proof.png"
// 第一次 claim 独立提交,回调成功;故意让 finalize UPDATE 失败,模拟来源已终态但 Admin 仍是 claimed。
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").WithArgs("yumi", uint(77), 1).
WillReturnRows(externalFinanceAuditRows("", "", 0, "", "", "", "", ""))
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").WithArgs("yumi", uint(77), 1).
WillReturnRows(externalFinanceAuditRows(model.WithdrawalExternalFinanceClaimStatusClaimed, model.WithdrawalApplicationStatusApproved, 9, "first-finance", "first remark", firstImage, commandID, ""))
finalizeFailure := errors.New("admin finalize failed")
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnError(finalizeFailure)
mock.ExpectRollback()
_, err := svc.executeExternalFinanceDecision(context.Background(), shared.Actor{UserID: 9, Username: "first-finance"}, 77, "yumi", model.WithdrawalApplicationStatusApproved, "first remark", firstImage, commandID, 1700000300000)
if !errors.Is(err, finalizeFailure) {
t.Fatalf("first callback must surface local finalize failure, got %v", err)
}
// 第二个财务人使用不同备注/凭证重试claim 不 UPDATE回调仍必须使用第一个人的完整快照。
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").WithArgs("yumi", uint(77), 1).
WillReturnRows(externalFinanceAuditRows(model.WithdrawalExternalFinanceClaimStatusClaimed, model.WithdrawalApplicationStatusApproved, 9, "first-finance", "first remark", firstImage, commandID, ""))
mock.ExpectCommit()
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").WithArgs("yumi", uint(77), 1).
WillReturnRows(externalFinanceAuditRows(model.WithdrawalExternalFinanceClaimStatusClaimed, model.WithdrawalApplicationStatusApproved, 9, "first-finance", "first remark", firstImage, commandID, ""))
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
dto, err := svc.executeExternalFinanceDecision(context.Background(), shared.Actor{UserID: 19, Username: "retry-finance"}, 77, "yumi", model.WithdrawalApplicationStatusApproved, "changed remark", "https://example.com/changed.png", commandID, 1700000400000)
if err != nil {
t.Fatalf("retry external finance decision failed: %v", err)
}
if dto.Status != model.WithdrawalApplicationStatusApproved || dto.ApproverUserID == nil || *dto.ApproverUserID != 9 || dto.ApproverName != "first-finance" || dto.AuditRemark != "first remark" || dto.AuditImageURL != firstImage {
t.Fatalf("retry terminal state did not preserve first claim: %+v", dto)
}
firstCallback, secondCallback := <-callbacks, <-callbacks
for index, payload := range []withdrawalsource.DecisionRequest{firstCallback, secondCallback} {
if payload.Decision != "PASS" || payload.ReviewerUserID != 9 || payload.ReviewerName != "first-finance" || payload.Remark != "first remark" || payload.CommandID != commandID || len(payload.CredentialURLs) != 1 || payload.CredentialURLs[0] != firstImage {
t.Fatalf("callback %d did not use first durable claim: %+v", index+1, payload)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestOperationsRejectionReleasesFrozenBalanceAndSendsFinalNotice(t *testing.T) {
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
expectWithdrawalMoneyAccess(mock, 8, "lalu")
expectWithdrawalRejectionClaim(mock, model.WithdrawalOperationsStatusPending)
expectWithdrawalRejectionFinalize(mock)
dto, err := svc.RejectOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
UserID: 8, Username: "operations", Permissions: []string{permissionAuditOperationsWithdrawals},
}, 77, "收款资料不符", "request-ops-reject")
if err != nil {
t.Fatalf("reject operations withdrawal failed: %v", err)
}
if wallet.releaseSalary == nil || wallet.releaseSalary.GetCommandId() != "salary-withdrawal:77:operations" || wallet.releaseSalary.GetSalaryUsdMinor() != 5000 {
t.Fatalf("operations rejection release mismatch: %+v", wallet.releaseSalary)
}
if wallet.settleSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil {
t.Fatalf("operations rejection must only release matching salary asset: %+v", wallet)
}
if activity.calls != 1 || activity.last.GetProducerEventId() != "operations-withdrawal:77:rejected" || activity.last.GetProducerEventType() != "operations_withdrawal_rejected" {
t.Fatalf("operations rejection final notice mismatch: calls=%d request=%+v", activity.calls, activity.last)
}
if dto.Status != model.WithdrawalApplicationStatusRejected || dto.OperationsStatus != model.WithdrawalOperationsStatusRejected || dto.OperationsAuditTransactionID != "salary-release-tx" {
t.Fatalf("operations rejection state mismatch: %+v", dto)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestOperationsRejectionNoticeFailureStaysRejectingAndRetryKeepsFirstAuditContext(t *testing.T) {
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
noticeFailure := errors.New("activity unavailable")
activity.failures = []error{noticeFailure, nil}
expectWithdrawalMoneyAccess(mock, 8, "lalu")
expectWithdrawalRejectionClaim(mock, model.WithdrawalOperationsStatusPending)
_, err := svc.RejectOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
UserID: 8, Username: "first-operator", Permissions: []string{permissionAuditOperationsWithdrawals},
}, 77, "first rejection reason", "request-ops-reject-first")
if !errors.Is(err, noticeFailure) {
t.Fatalf("first rejection must surface notice failure, got %v", err)
}
// 第二个运营人员重试时只能沿用第一次 claim 快照;数据库 rejecting 行禁止改点通过,也不允许改写拒绝原因。
expectWithdrawalMoneyAccess(mock, 18, "lalu")
expectWithdrawalRejectionRetryClaim(mock, 8, "first-operator", "first rejection reason")
expectWithdrawalRejectionFinalizeWithContext(mock, 8, "first-operator", "first rejection reason")
dto, err := svc.RejectOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
UserID: 18, Username: "retry-operator", Permissions: []string{permissionAuditOperationsWithdrawals},
}, 77, "changed retry reason", "request-ops-reject-retry")
if err != nil {
t.Fatalf("retry rejecting withdrawal failed: %v", err)
}
if dto.OperationsStatus != model.WithdrawalOperationsStatusRejected || wallet.releaseSalary.GetOperatorUserId() != 8 || wallet.releaseSalary.GetReason() != "first rejection reason" {
t.Fatalf("retry must preserve first claim context: dto=%+v wallet=%+v", dto, wallet.releaseSalary)
}
if activity.calls != 2 || activity.last == nil || activity.last.GetBody() != "Your withdrawal request has been rejected. Reason: first rejection reason" {
t.Fatalf("retry notice must preserve first rejection reason: calls=%d request=%+v", activity.calls, activity.last)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestOperationsApprovalRejectsDurableRejectingClaimBeforeWalletOrNotice(t *testing.T) {
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
expectWithdrawalMoneyAccess(mock, 18, "lalu")
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
WithArgs("lalu", uint(77), 1).
WillReturnRows(withdrawalAuditRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, 8, "first-operator", "first rejection reason"))
mock.ExpectRollback()
_, err := svc.ApproveOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
UserID: 18, Username: "retry-operator", Permissions: []string{permissionAuditOperationsWithdrawals},
}, 77, "改为通过", "request-ops-approve-after-reject")
if !errors.Is(err, repository.ErrWithdrawalApplicationStageNotReviewable) {
t.Fatalf("rejecting claim must block operations approval, got %v", err)
}
if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 {
t.Fatalf("rejecting gate must run before integrations: wallet=%+v notices=%d", wallet, activity.calls)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestFinanceReviewRejectsOperationsPendingBeforeWalletOrNotice(t *testing.T) {
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
expectWithdrawalMoneyAccess(mock, 9, "lalu")
// 财务入口先识别外部来源hyapp_wallet 行仍进入原有持锁审核事务。
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications` WHERE app_code = \\? AND id = \\?").
WithArgs("lalu", uint(77), 1).
WillReturnRows(withdrawalAuditRows(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending))
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
WithArgs("lalu", uint(77), 1).
WillReturnRows(withdrawalAuditRows(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending))
mock.ExpectRollback()
_, err := svc.ApproveApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
UserID: 9, Username: "finance", Permissions: []string{permissionAuditWithdrawalApplications},
}, 77, "已打款", "https://example.com/proof.png", "request-finance-approve")
if !errors.Is(err, repository.ErrWithdrawalApplicationStageNotReviewable) {
t.Fatalf("finance must reject operations-pending application, got %v", err)
}
if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 {
t.Fatalf("stage gate must run before wallet or notice: wallet=%+v notice_calls=%d", wallet, activity.calls)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestOperationsListRejectsExplicitAppOutsideMoneyScope(t *testing.T) {
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
expectWithdrawalMoneyAccess(mock, 8, "lalu")
_, _, err := svc.ListOperationsApplications(shared.Actor{UserID: 8}, repository.WithdrawalApplicationListOptions{AppCode: "huwaa"})
if !errors.Is(err, errWithdrawalMoneyScopeForbidden) {
t.Fatalf("out-of-scope list must be forbidden, got %v", err)
}
if wallet.settleSalary != nil || wallet.releaseSalary != nil || activity.calls != 0 {
t.Fatalf("out-of-scope list must not call integrations: wallet=%+v notices=%d", wallet, activity.calls)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestOperationsListWithoutAppRestrictsToMoneyScopeApps(t *testing.T) {
svc, mock, _, _, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
expectWithdrawalMoneyAccess(mock, 8, "lalu", "fami")
mock.ExpectQuery("SELECT count\\(\\*\\) FROM `admin_user_withdrawal_applications`.*app_code IN.*operations_status <> \\?").
WithArgs("fami", "lalu", model.WithdrawalOperationsStatusSkipped).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*app_code IN.*operations_status <> \\?").
WithArgs("fami", "lalu", model.WithdrawalOperationsStatusSkipped, 20).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
items, total, err := svc.ListOperationsApplications(shared.Actor{UserID: 8}, repository.WithdrawalApplicationListOptions{Page: 1, PageSize: 20})
if err != nil || total != 0 || len(items) != 0 {
t.Fatalf("money-scoped operations list mismatch: total=%d items=%+v err=%v", total, items, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestFinanceAuditRejectsHeaderAppOutsideMoneyScopeBeforeWallet(t *testing.T) {
svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t)
defer closeService()
expectWithdrawalMoneyAccess(mock, 9, "fami")
_, err := svc.ApproveApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{
UserID: 9, Username: "finance", Permissions: []string{permissionAuditWithdrawalApplications},
}, 77, "paid", "https://example.com/proof.png", "request-out-of-scope")
if !errors.Is(err, errWithdrawalMoneyScopeForbidden) {
t.Fatalf("out-of-scope audit must be forbidden, got %v", err)
}
if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 {
t.Fatalf("money scope gate must run before row lock, wallet, or notice: wallet=%+v notices=%d", wallet, activity.calls)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestWithdrawalAuditNotificationEventIDPreservesFinanceCompatibility(t *testing.T) {
t.Parallel()
if got := withdrawalAuditNotificationEventID(77, repository.WithdrawalApplicationReviewStageFinance, model.WithdrawalApplicationStatusApproved); got != "finance-withdrawal:77:approved" {
t.Fatalf("finance event id = %q", got)
}
if got := withdrawalAuditNotificationEventID(77, repository.WithdrawalApplicationReviewStageOperations, model.WithdrawalApplicationStatusRejected); got != "operations-withdrawal:77:rejected" {
t.Fatalf("operations event id = %q", got)
}
}
func TestApplyWalletDecisionRoutesPointApprovalToPointSettlement(t *testing.T) {
wallet := &fakeAuditWalletClient{}
svc := &Service{wallet: wallet}
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusApproved, "audit:77:approved", "fami", 42001, "POINT", 1_000_000, 25_000, 975_000, 200_000, 250, shared.Actor{UserID: 90001}, "paid", 77)
if err != nil {
t.Fatalf("apply point approval failed: %v", err)
}
if transactionID != "point-settle-tx" {
t.Fatalf("point approval transaction id mismatch: %s", transactionID)
}
if wallet.settlePoint == nil ||
wallet.settlePoint.GetCommandId() != "audit:77:approved" ||
wallet.settlePoint.GetAppCode() != "fami" ||
wallet.settlePoint.GetUserId() != 42001 ||
wallet.settlePoint.GetAssetType() != "POINT" ||
wallet.settlePoint.GetGrossPointAmount() != 1_000_000 ||
wallet.settlePoint.GetFeePointAmount() != 25_000 ||
wallet.settlePoint.GetNetPointAmount() != 975_000 ||
wallet.settlePoint.GetPointsPerUsd() != 200_000 ||
wallet.settlePoint.GetFeeBps() != 250 ||
wallet.settlePoint.GetOperatorUserId() != 90001 ||
wallet.settlePoint.GetWithdrawalApplicationId() != "77" {
t.Fatalf("SettlePointWithdrawal payload mismatch: %+v", wallet.settlePoint)
}
if wallet.settleSalary != nil || wallet.releasePoint != nil || wallet.releaseSalary != nil {
t.Fatalf("point approval must not call legacy salary or release RPCs: wallet=%+v", wallet)
}
}
func TestApplyWalletDecisionRoutesPointRejectionToPointRelease(t *testing.T) {
wallet := &fakeAuditWalletClient{}
svc := &Service{wallet: wallet}
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusRejected, "audit:78:rejected", "huwaa", 42002, "COIN_SELLER_POINT", 2_000_000, 100_000, 1_900_000, 100_000, 500, shared.Actor{UserID: 90002}, "bad address", 78)
if err != nil {
t.Fatalf("apply point rejection failed: %v", err)
}
if transactionID != "point-release-tx" {
t.Fatalf("point rejection transaction id mismatch: %s", transactionID)
}
if wallet.releasePoint == nil ||
wallet.releasePoint.GetCommandId() != "audit:78:rejected" ||
wallet.releasePoint.GetAppCode() != "huwaa" ||
wallet.releasePoint.GetUserId() != 42002 ||
wallet.releasePoint.GetAssetType() != "COIN_SELLER_POINT" ||
wallet.releasePoint.GetGrossPointAmount() != 2_000_000 ||
wallet.releasePoint.GetFeePointAmount() != 100_000 ||
wallet.releasePoint.GetNetPointAmount() != 1_900_000 ||
wallet.releasePoint.GetOperatorUserId() != 90002 ||
wallet.releasePoint.GetReason() != "bad address" ||
wallet.releasePoint.GetWithdrawalApplicationId() != "78" {
t.Fatalf("ReleasePointWithdrawal payload mismatch: %+v", wallet.releasePoint)
}
if wallet.settlePoint != nil || wallet.settleSalary != nil || wallet.releaseSalary != nil {
t.Fatalf("point rejection must not call legacy salary or settle RPCs: wallet=%+v", wallet)
}
}
func TestApplyWalletDecisionKeepsLegacySalaryAssetsOnSalaryRPC(t *testing.T) {
wallet := &fakeAuditWalletClient{}
svc := &Service{wallet: wallet}
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusApproved, "audit:79:approved", "lalu", 42003, "HOST_SALARY_USD", 12_345, 0, 0, 0, 0, shared.Actor{UserID: 90003}, "", 79)
if err != nil {
t.Fatalf("apply salary approval failed: %v", err)
}
if transactionID != "salary-settle-tx" {
t.Fatalf("salary approval transaction id mismatch: %s", transactionID)
}
if wallet.settleSalary == nil ||
wallet.settleSalary.GetAppCode() != "lalu" ||
wallet.settleSalary.GetUserId() != 42003 ||
wallet.settleSalary.GetSalaryAssetType() != "HOST_SALARY_USD" ||
wallet.settleSalary.GetSalaryUsdMinor() != 12_345 ||
wallet.settleSalary.GetWithdrawalApplicationId() != "79" {
t.Fatalf("SettleSalaryWithdrawal payload mismatch: %+v", wallet.settleSalary)
}
if wallet.settlePoint != nil || wallet.releasePoint != nil {
t.Fatalf("legacy salary asset must not call point RPCs: wallet=%+v", wallet)
}
}
type fakeAuditWalletClient struct {
walletclient.Client
settlePoint *walletv1.SettlePointWithdrawalRequest
releasePoint *walletv1.ReleasePointWithdrawalRequest
settleSalary *walletv1.SettleSalaryWithdrawalRequest
releaseSalary *walletv1.ReleaseSalaryWithdrawalRequest
}
func (f *fakeAuditWalletClient) SettlePointWithdrawal(_ context.Context, req *walletv1.SettlePointWithdrawalRequest) (*walletv1.SettlePointWithdrawalResponse, error) {
f.settlePoint = req
return &walletv1.SettlePointWithdrawalResponse{TransactionId: "point-settle-tx"}, nil
}
func (f *fakeAuditWalletClient) ReleasePointWithdrawal(_ context.Context, req *walletv1.ReleasePointWithdrawalRequest) (*walletv1.ReleasePointWithdrawalResponse, error) {
f.releasePoint = req
return &walletv1.ReleasePointWithdrawalResponse{TransactionId: "point-release-tx"}, nil
}
func (f *fakeAuditWalletClient) SettleSalaryWithdrawal(_ context.Context, req *walletv1.SettleSalaryWithdrawalRequest) (*walletv1.SettleSalaryWithdrawalResponse, error) {
f.settleSalary = req
return &walletv1.SettleSalaryWithdrawalResponse{TransactionId: "salary-settle-tx"}, nil
}
func (f *fakeAuditWalletClient) ReleaseSalaryWithdrawal(_ context.Context, req *walletv1.ReleaseSalaryWithdrawalRequest) (*walletv1.ReleaseSalaryWithdrawalResponse, error) {
f.releaseSalary = req
return &walletv1.ReleaseSalaryWithdrawalResponse{TransactionId: "salary-release-tx"}, nil
}
type fakeAuditActivityClient struct {
activityclient.Client
last *activityv1.CreateInboxMessageRequest
calls int
failures []error
}
func (f *fakeAuditActivityClient) CreateInboxMessage(_ context.Context, req *activityv1.CreateInboxMessageRequest) (*activityv1.CreateInboxMessageResponse, error) {
f.last = req
f.calls++
if index := f.calls - 1; index < len(f.failures) && f.failures[index] != nil {
return nil, f.failures[index]
}
return &activityv1.CreateInboxMessageResponse{}, nil
}
func newWithdrawalAuditServiceTest(t *testing.T) (*Service, sqlmock.Sqlmock, *fakeAuditWalletClient, *fakeAuditActivityClient, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sql mock failed: %v", err)
}
gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("create gorm db failed: %v", err)
}
wallet := &fakeAuditWalletClient{}
activity := &fakeAuditActivityClient{}
svc := &Service{
store: repository.New(gormDB), wallet: wallet, activity: activity,
now: func() time.Time { return time.UnixMilli(1_700_000_300_000).UTC() },
}
return svc, mock, wallet, activity, func() { _ = sqlDB.Close() }
}
func expectWithdrawalReviewLock(mock sqlmock.Sqlmock, status string, operationsStatus string) {
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
WithArgs("lalu", uint(77), 1).
WillReturnRows(withdrawalAuditRows(status, operationsStatus))
}
func expectWithdrawalRejectionClaim(mock sqlmock.Sqlmock, operationsStatus string) {
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
WithArgs("lalu", uint(77), 1).
WillReturnRows(withdrawalAuditRows(model.WithdrawalApplicationStatusPending, operationsStatus))
if operationsStatus == model.WithdrawalOperationsStatusPending {
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
}
mock.ExpectCommit()
}
func expectWithdrawalRejectionRetryClaim(mock sqlmock.Sqlmock, reviewerID uint, reviewerName string, remark string) {
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
WithArgs("lalu", uint(77), 1).
WillReturnRows(withdrawalAuditRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, reviewerID, reviewerName, remark))
mock.ExpectCommit()
}
func expectWithdrawalRejectionFinalize(mock sqlmock.Sqlmock) {
expectWithdrawalRejectionFinalizeWithContext(mock, 8, "operations", "收款资料不符")
}
func expectWithdrawalRejectionFinalizeWithContext(mock sqlmock.Sqlmock, reviewerID uint, reviewerName string, remark string) {
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
WithArgs("lalu", uint(77), 1).
WillReturnRows(withdrawalAuditRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, reviewerID, reviewerName, remark))
mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
}
func expectWithdrawalMoneyAccess(mock sqlmock.Sqlmock, userID uint, appCodes ...string) {
mock.ExpectQuery("SELECT \\* FROM `admin_users` WHERE `admin_users`.`id` = \\? ORDER BY `admin_users`.`id` LIMIT \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "username", "name", "status"}).AddRow(userID, "reviewer", "Reviewer", "active"))
mock.ExpectQuery("SELECT \\* FROM `admin_user_roles` WHERE `admin_user_roles`.`user_id` = \\?").
WillReturnRows(sqlmock.NewRows([]string{"user_id", "role_id"}))
rows := sqlmock.NewRows([]string{"id", "user_id", "app_code", "region_id", "created_at_ms", "updated_at_ms"})
for index, appCode := range appCodes {
rows.AddRow(index+1, userID, appCode, 0, int64(1_700_000_000_000), int64(1_700_000_000_000))
}
mock.ExpectQuery("SELECT \\* FROM `admin_user_money_scopes` WHERE user_id = \\? ORDER BY app_code ASC, region_id ASC").
WillReturnRows(rows)
}
func withdrawalAuditRows(status string, operationsStatus string) *sqlmock.Rows {
return withdrawalAuditRowsWithOperationsContext(status, operationsStatus, 0, "", "")
}
func withdrawalAuditRowsWithOperationsContext(status string, operationsStatus string, reviewerID uint, reviewerName string, remark string) *sqlmock.Rows {
var storedReviewerID any
var reviewedAtMS any
operationsCommandID := ""
if reviewerID > 0 {
storedReviewerID = reviewerID
reviewedAtMS = int64(1_700_000_300_000)
operationsCommandID = "salary-withdrawal:77:operations"
}
return sqlmock.NewRows([]string{
"id", "app_code", "user_id", "salary_asset_type", "withdraw_amount", "withdraw_amount_minor",
"withdraw_method", "withdraw_address", "freeze_command_id", "freeze_transaction_id",
"audit_command_id", "audit_transaction_id", "status", "operations_status",
"approver_user_id", "approver_name", "audit_remark", "audit_image_url", "approved_at_ms",
"operations_reviewer_user_id", "operations_reviewer_name", "operations_audit_remark",
"operations_audit_command_id", "operations_audit_transaction_id", "operations_reviewed_at_ms",
"created_at_ms", "updated_at_ms",
}).AddRow(
77, "lalu", "42001", "HOST_SALARY_USD", "50.00", int64(5000),
"usdt_trc20", "TRON-address", "freeze-command", "freeze-tx",
"", "", status, operationsStatus, nil, "", "", "", nil,
storedReviewerID, reviewerName, remark, operationsCommandID, "", reviewedAtMS,
int64(1_700_000_000_000), int64(1_700_000_000_000),
)
}
func externalFinanceAuditRows(claimStatus string, claimDecision string, reviewerID uint, reviewerName string, remark string, imageURL string, commandID string, transactionID string) *sqlmock.Rows {
var storedClaimStatus any
var storedClaimDecision any
var storedReviewerID any
var approvedAtMS any
if claimStatus != "" {
storedClaimStatus = claimStatus
storedClaimDecision = claimDecision
storedReviewerID = reviewerID
approvedAtMS = int64(1_700_000_300_000)
}
return sqlmock.NewRows([]string{
"id", "app_code", "source_system", "source_application_id", "user_id", "salary_asset_type",
"withdraw_amount", "withdraw_amount_minor", "withdraw_method", "withdraw_address", "status", "operations_status",
"external_finance_claim_decision", "external_finance_claim_status", "approver_user_id", "approver_name",
"audit_remark", "audit_image_url", "audit_command_id", "audit_transaction_id", "approved_at_ms", "created_at_ms", "updated_at_ms",
}).AddRow(
77, "yumi", "chatapp3", "88001", "42001", "SALARY", "84.00", int64(8400), "usdt_trc20", "TRON-address",
model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusApproved,
storedClaimDecision, storedClaimStatus, storedReviewerID, reviewerName, remark, imageURL, commandID, transactionID, approvedAtMS,
int64(1_700_000_000_000), int64(1_700_000_000_000),
)
}