diff --git a/backend/internal/handler/admin/affiliate_handler.go b/backend/internal/handler/admin/affiliate_handler.go index d443d344d2..0c2db16cd1 100644 --- a/backend/internal/handler/admin/affiliate_handler.go +++ b/backend/internal/handler/admin/affiliate_handler.go @@ -201,6 +201,36 @@ func (h *AffiliateHandler) GetUserOverview(c *gin.Context) { response.Success(c, overview) } +// WithdrawQuotaRequest 是登记线下提现的请求体。Amount 是已在站外打款给 +// 用户的金额,由 AffiliateService 校验。 +type WithdrawQuotaRequest struct { + Amount float64 `json:"amount"` +} + +// WithdrawQuota records an offline withdrawal paid outside the site and +// deducts it from the user's available affiliate quota. +// POST /api/v1/admin/affiliates/users/:user_id/withdraw +func (h *AffiliateHandler) WithdrawQuota(c *gin.Context) { + userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64) + if err != nil || userID <= 0 { + response.BadRequest(c, "Invalid user_id") + return + } + + var req WithdrawQuotaRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + result, err := h.affiliateService.AdminWithdrawQuota(c.Request.Context(), userID, req.Amount) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + // ListInviteRecords returns all inviter-invitee relationships. // GET /api/v1/admin/affiliates/invites func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) { @@ -214,7 +244,8 @@ func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) { response.Paginated(c, items, total, filter.Page, filter.PageSize) } -// ListRebateRecords returns all order-level affiliate rebate records. +// ListRebateRecords returns every affiliate rebate accrual, with order details +// when the rebate came from a payment order. // GET /api/v1/admin/affiliates/rebates func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) { page, pageSize := response.ParsePagination(c) @@ -227,7 +258,8 @@ func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) { response.Paginated(c, items, total, filter.Page, filter.PageSize) } -// ListTransferRecords returns all affiliate quota-to-balance transfer records. +// ListTransferRecords returns affiliate quota outflows: user transfers into +// balance and admin-recorded offline withdrawals. // GET /api/v1/admin/affiliates/transfers func (h *AffiliateHandler) ListTransferRecords(c *gin.Context) { page, pageSize := response.ParsePagination(c) diff --git a/backend/internal/handler/admin/affiliate_handler_withdraw_test.go b/backend/internal/handler/admin/affiliate_handler_withdraw_test.go new file mode 100644 index 0000000000..2ef1be66bf --- /dev/null +++ b/backend/internal/handler/admin/affiliate_handler_withdraw_test.go @@ -0,0 +1,105 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type withdrawQuotaCall struct { + userID int64 + amount float64 +} + +type withdrawQuotaRepoStub struct { + service.AffiliateRepository + calls []withdrawQuotaCall + result *service.AffiliateWithdrawResult + err error +} + +func (s *withdrawQuotaRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64) (*service.AffiliateWithdrawResult, error) { + s.calls = append(s.calls, withdrawQuotaCall{userID: userID, amount: amount}) + return s.result, s.err +} + +type withdrawQuotaResponse struct { + Code int `json:"code"` + Reason string `json:"reason"` + Data *service.AffiliateWithdrawResult `json:"data"` +} + +func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, body string) (int, withdrawQuotaResponse) { + t.Helper() + gin.SetMode(gin.TestMode) + router := gin.New() + handler := NewAffiliateHandler(service.NewAffiliateService(repo, nil, nil, nil), nil) + router.POST("/api/v1/admin/affiliates/users/:user_id/withdraw", handler.WithdrawQuota) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/affiliates/users/"+userID+"/withdraw", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + var resp withdrawQuotaResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + return rec.Code, resp +} + +// TestAffiliateHandlerWithdrawQuota_Success 验证登记线下提现把路径中的用户与 +// 舍入后的金额交给仓储,并返回登记结果。 +func TestAffiliateHandlerWithdrawQuota_Success(t *testing.T) { + repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}} + status, resp := performWithdrawQuota(t, repo, "42", `{"amount":12.3456789149}`) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, 0, resp.Code) + require.NotNil(t, resp.Data) + require.Equal(t, int64(9), resp.Data.LedgerID) + require.Equal(t, []withdrawQuotaCall{{userID: 42, amount: 12.34567891}}, repo.calls) +} + +// TestAffiliateHandlerWithdrawQuota_RejectsBadRequests 验证非法用户 ID、非法 JSON +// 与非法金额在进入仓储前以 400 拒绝;金额错误带 AFFILIATE_WITHDRAW_AMOUNT_INVALID。 +func TestAffiliateHandlerWithdrawQuota_RejectsBadRequests(t *testing.T) { + cases := []struct { + name string + userID string + body string + wantReason string + }{ + {name: "non-numeric user id", userID: "abc", body: `{"amount":1}`}, + {name: "non-positive user id", userID: "0", body: `{"amount":1}`}, + {name: "malformed json", userID: "42", body: `{"amount":`}, + {name: "zero amount", userID: "42", body: `{"amount":0}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"}, + {name: "missing amount", userID: "42", body: `{}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := &withdrawQuotaRepoStub{} + status, resp := performWithdrawQuota(t, repo, tc.userID, tc.body) + + require.Equal(t, http.StatusBadRequest, status) + require.Equal(t, tc.wantReason, resp.Reason) + require.Empty(t, repo.calls) + }) + } +} + +// TestAffiliateHandlerWithdrawQuota_InsufficientQuota 验证额度不足以 400 和 +// AFFILIATE_QUOTA_INSUFFICIENT 返回,前端据此提示。 +func TestAffiliateHandlerWithdrawQuota_InsufficientQuota(t *testing.T) { + repo := &withdrawQuotaRepoStub{err: service.ErrAffiliateQuotaInsufficient} + status, resp := performWithdrawQuota(t, repo, "42", `{"amount":5}`) + + require.Equal(t, http.StatusBadRequest, status) + require.Equal(t, "AFFILIATE_QUOTA_INSUFFICIENT", resp.Reason) + require.Len(t, repo.calls, 1) +} diff --git a/backend/internal/handler/auth_email_oauth_test.go b/backend/internal/handler/auth_email_oauth_test.go index 493b509989..18e3ea6725 100644 --- a/backend/internal/handler/auth_email_oauth_test.go +++ b/backend/internal/handler/auth_email_oauth_test.go @@ -407,6 +407,10 @@ func (r *oauthEmailAffiliateRepoStub) TransferQuotaToBalance(context.Context, in panic("unexpected TransferQuotaToBalance call") } +func (r *oauthEmailAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64) (*service.AffiliateWithdrawResult, error) { + panic("unexpected WithdrawQuota call") +} + func (r *oauthEmailAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]service.AffiliateInvitee, error) { panic("unexpected ListInvitees call") } diff --git a/backend/internal/repository/affiliate_repo.go b/backend/internal/repository/affiliate_repo.go index 61da539e25..3553600557 100644 --- a/backend/internal/repository/affiliate_repo.go +++ b/backend/internal/repository/affiliate_repo.go @@ -341,6 +341,102 @@ VALUES ($1, 'transfer', $2, NULL, $3, $4, $5, $6, NOW(), NOW())`, return transferred, newBalance, nil } +// WithdrawQuota 登记一笔线下提现:先解冻已到期的冻结额度,再以 +// aff_quota >= amount 为条件原子扣减,并写入带额度快照的 withdraw 流水。 +// 可提取额度不足返回 ErrAffiliateQuotaInsufficient。 +func (r *affiliateRepository) WithdrawQuota(ctx context.Context, userID int64, amount float64) (*service.AffiliateWithdrawResult, error) { + if userID <= 0 { + return nil, service.ErrUserNotFound + } + if amount <= 0 { + return nil, service.ErrAffiliateWithdrawAmountInvalid + } + + var result *service.AffiliateWithdrawResult + err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error { + if _, err := thawFrozenQuotaTx(txCtx, txClient, userID); err != nil { + return fmt.Errorf("thaw before withdraw: %w", err) + } + + res, err := txClient.ExecContext(txCtx, ` +UPDATE user_affiliates +SET aff_quota = aff_quota - $1, + updated_at = NOW() +WHERE user_id = $2 + AND aff_quota >= $1`, amount, userID) + if err != nil { + return fmt.Errorf("deduct affiliate quota: %w", err) + } + affected, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("deduct affiliate quota: %w", err) + } + if affected == 0 { + return service.ErrAffiliateQuotaInsufficient + } + + snapshot, err := queryAffiliateTransferSnapshot(txCtx, txClient, userID) + if err != nil { + return err + } + + rows, err := txClient.QueryContext(txCtx, ` +INSERT INTO user_affiliate_ledger ( + user_id, + action, + amount, + source_user_id, + balance_after, + aff_quota_after, + aff_frozen_quota_after, + aff_history_quota_after, + created_at, + updated_at +) +VALUES ($1, 'withdraw', $2, NULL, $3, $4, $5, $6, NOW(), NOW()) +RETURNING id`, + userID, + amount, + snapshot.BalanceAfter, + snapshot.AvailableQuotaAfter, + snapshot.FrozenQuotaAfter, + snapshot.HistoryQuotaAfter, + ) + if err != nil { + return fmt.Errorf("insert affiliate withdraw ledger: %w", err) + } + if !rows.Next() { + _ = rows.Close() + if err := rows.Err(); err != nil { + return fmt.Errorf("insert affiliate withdraw ledger: %w", err) + } + return errors.New("insert affiliate withdraw ledger: no id returned") + } + var ledgerID int64 + if err := rows.Scan(&ledgerID); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + + result = &service.AffiliateWithdrawResult{ + LedgerID: ledgerID, + UserID: userID, + Amount: amount, + AvailableQuotaAfter: snapshot.AvailableQuotaAfter, + FrozenQuotaAfter: snapshot.FrozenQuotaAfter, + HistoryQuotaAfter: snapshot.HistoryQuotaAfter, + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + func (r *affiliateRepository) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]service.AffiliateInvitee, error) { if limit <= 0 { limit = 100 @@ -466,13 +562,14 @@ func (r *affiliateRepository) ListAffiliateRebateRecords(ctx context.Context, fi "inviter.email", "inviter.username", "invitee.email", "invitee.username", "po.id::text", "po.out_trade_no", "po.payment_type", "po.status", }) + // 返利记录列出全部 accrue 流水:非订单来源的流水没有订单,被邀请人账号 + // 删除后 source_user_id 为 NULL,这两类行都保留。 baseJoin := ` FROM user_affiliate_ledger ual -JOIN payment_orders po ON po.id = ual.source_order_id -JOIN users invitee ON invitee.id = ual.source_user_id JOIN users inviter ON inviter.id = ual.user_id -WHERE ual.action = 'accrue' - AND ual.source_order_id IS NOT NULL` +LEFT JOIN users invitee ON invitee.id = ual.source_user_id +LEFT JOIN payment_orders po ON po.id = ual.source_order_id +WHERE ual.action = 'accrue'` if where != "" { where = strings.Replace(where, "WHERE ", " AND ", 1) } @@ -496,7 +593,7 @@ WHERE ual.action = 'accrue' args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize) rows, err := client.QueryContext(ctx, ` SELECT po.id, - po.out_trade_no, + COALESCE(po.out_trade_no, ''), ual.user_id, COALESCE(inviter.email, ''), COALESCE(inviter.username, ''), @@ -506,8 +603,8 @@ SELECT po.id, po.amount::double precision, po.pay_amount::double precision, ual.amount::double precision, - po.payment_type, - po.status, + COALESCE(po.payment_type, ''), + COALESCE(po.status, ''), ual.created_at `+baseJoin+where+` `+orderBy+` @@ -520,17 +617,21 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) items := make([]service.AffiliateRebateRecord, 0) for rows.Next() { var item service.AffiliateRebateRecord + var orderID sql.NullInt64 + var inviteeID sql.NullInt64 + var orderAmount sql.NullFloat64 + var payAmount sql.NullFloat64 if err := rows.Scan( - &item.OrderID, + &orderID, &item.OutTradeNo, &item.InviterID, &item.InviterEmail, &item.InviterUsername, - &item.InviteeID, + &inviteeID, &item.InviteeEmail, &item.InviteeUsername, - &item.OrderAmount, - &item.PayAmount, + &orderAmount, + &payAmount, &item.RebateAmount, &item.PaymentType, &item.OrderStatus, @@ -538,6 +639,10 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) ); err != nil { return nil, 0, err } + item.OrderID = nullableInt64Ptr(orderID) + item.InviteeID = nullableInt64Ptr(inviteeID) + item.OrderAmount = nullableFloat64Ptr(orderAmount) + item.PayAmount = nullableFloat64Ptr(payAmount) items = append(items, item) } if err := rows.Err(); err != nil { @@ -554,7 +659,7 @@ func (r *affiliateRepository) ListAffiliateTransferRecords(ctx context.Context, baseJoin := ` FROM user_affiliate_ledger ual JOIN users u ON u.id = ual.user_id -WHERE ual.action = 'transfer'` +WHERE ual.action IN ('transfer', 'withdraw')` if where != "" { where = strings.Replace(where, "WHERE ", " AND ", 1) } @@ -566,6 +671,7 @@ WHERE ual.action = 'transfer'` orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{ "user": "u.email", + "action": "ual.action", "amount": "ual.amount", "balance_after": "ual.balance_after", "available_quota_after": "ual.aff_quota_after", @@ -576,6 +682,7 @@ WHERE ual.action = 'transfer'` args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize) rows, err := client.QueryContext(ctx, ` SELECT ual.id, + ual.action, ual.user_id, COALESCE(u.email, ''), COALESCE(u.username, ''), @@ -602,6 +709,7 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) var historyQuotaAfter sql.NullFloat64 if err := rows.Scan( &item.LedgerID, + &item.Action, &item.UserID, &item.UserEmail, &item.Username, @@ -958,6 +1066,13 @@ func nullableFloat64Ptr(v sql.NullFloat64) *float64 { return &v.Float64 } +func nullableInt64Ptr(v sql.NullInt64) *int64 { + if !v.Valid { + return nil + } + return &v.Int64 +} + func generateAffiliateCode() (string, error) { buf := make([]byte, affiliateCodeLength) if _, err := rand.Read(buf); err != nil { diff --git a/backend/internal/repository/affiliate_repo_integration_test.go b/backend/internal/repository/affiliate_repo_integration_test.go index b01ed528a6..f74da3da6f 100644 --- a/backend/internal/repository/affiliate_repo_integration_test.go +++ b/backend/internal/repository/affiliate_repo_integration_test.go @@ -417,3 +417,159 @@ func TestAffiliateRepository_ListUsersWithCustomSettings(t *testing.T) { require.GreaterOrEqual(t, total, int64(2), "total must include at least our 2 custom rows") } + +func mustCreateAffiliateWithdrawUser(t *testing.T, client *dbent.Client, label string) *service.User { + t.Helper() + return mustCreateUser(t, client, &service.User{ + Email: fmt.Sprintf("affiliate-withdraw-%s-%d@example.com", label, time.Now().UnixNano()), + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + Balance: 5.5, + Concurrency: 5, + }) +} + +func mustSeedAffiliateQuota(t *testing.T, ctx context.Context, client *dbent.Client, userID int64, quota, frozen, history float64) { + t.Helper() + affCode := fmt.Sprintf("AFW%d-%06d", userID, time.Now().UnixNano()%1_000_000) + _, err := client.ExecContext(ctx, ` +INSERT INTO user_affiliates (user_id, aff_code, aff_quota, aff_frozen_quota, aff_history_quota, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, NOW(), NOW())`, userID, affCode, quota, frozen, history) + require.NoError(t, err) +} + +// TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger 覆盖线下提现主路径: +// 只扣可提取额度,余额与累计返利不变,流水 action=withdraw 带额度快照,并出现在提取记录中。 +func TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + txCtx := dbent.NewTxContext(ctx, tx) + client := tx.Client() + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateAffiliateWithdrawUser(t, client, "user") + mustSeedAffiliateQuota(t, txCtx, client, u.ID, 20, 0, 30) + + result, err := repo.WithdrawQuota(txCtx, u.ID, 12.5) + require.NoError(t, err) + require.Positive(t, result.LedgerID) + require.InDelta(t, 12.5, result.Amount, 1e-9) + require.InDelta(t, 7.5, result.AvailableQuotaAfter, 1e-9) + require.InDelta(t, 0.0, result.FrozenQuotaAfter, 1e-9) + require.InDelta(t, 30.0, result.HistoryQuotaAfter, 1e-9) + + require.InDelta(t, 7.5, querySingleFloat(t, txCtx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.InDelta(t, 30.0, querySingleFloat(t, txCtx, client, + "SELECT aff_history_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.InDelta(t, 5.5, querySingleFloat(t, txCtx, client, + "SELECT balance::double precision FROM users WHERE id = $1", u.ID), 1e-9) + + records, total, err := repo.ListAffiliateTransferRecords(txCtx, service.AffiliateRecordFilter{ + Search: u.Email, + Page: 1, + PageSize: 20, + SortDesc: true, + }) + require.NoError(t, err) + require.EqualValues(t, 1, total) + require.Len(t, records, 1) + record := records[0] + require.Equal(t, result.LedgerID, record.LedgerID) + require.Equal(t, "withdraw", record.Action) + require.Equal(t, u.ID, record.UserID) + require.InDelta(t, 12.5, record.Amount, 1e-9) + require.True(t, record.SnapshotAvailable) + require.InDelta(t, 5.5, *record.BalanceAfter, 1e-9) + require.InDelta(t, 7.5, *record.AvailableQuotaAfter, 1e-9) + require.InDelta(t, 30.0, *record.HistoryQuotaAfter, 1e-9) +} + +// TestAffiliateRepository_WithdrawQuota_ThawsMaturedQuotaFirst 验证已过冻结期 +// 但尚未解冻的额度在扣减前解冻并可被提现。 +func TestAffiliateRepository_WithdrawQuota_ThawsMaturedQuotaFirst(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + txCtx := dbent.NewTxContext(ctx, tx) + client := tx.Client() + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateAffiliateWithdrawUser(t, client, "thaw") + mustSeedAffiliateQuota(t, txCtx, client, u.ID, 0, 10, 10) + _, err := client.ExecContext(txCtx, ` +INSERT INTO user_affiliate_ledger (user_id, action, amount, frozen_until, created_at, updated_at) +VALUES ($1, 'accrue', 10, NOW() - INTERVAL '1 hour', NOW(), NOW())`, u.ID) + require.NoError(t, err) + + result, err := repo.WithdrawQuota(txCtx, u.ID, 10) + require.NoError(t, err) + require.InDelta(t, 0.0, result.AvailableQuotaAfter, 1e-9) + require.InDelta(t, 0.0, result.FrozenQuotaAfter, 1e-9) + require.Equal(t, 1, querySingleInt(t, txCtx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID)) +} + +// TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched 验证未到期的 +// 冻结额度不可提现,额度不足时不扣减、不写流水。 +func TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + txCtx := dbent.NewTxContext(ctx, tx) + client := tx.Client() + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateAffiliateWithdrawUser(t, client, "insufficient") + mustSeedAffiliateQuota(t, txCtx, client, u.ID, 5, 10, 15) + _, err := client.ExecContext(txCtx, ` +INSERT INTO user_affiliate_ledger (user_id, action, amount, frozen_until, created_at, updated_at) +VALUES ($1, 'accrue', 10, NOW() + INTERVAL '1 hour', NOW(), NOW())`, u.ID) + require.NoError(t, err) + + _, err = repo.WithdrawQuota(txCtx, u.ID, 6) + require.ErrorIs(t, err, service.ErrAffiliateQuotaInsufficient) + + require.InDelta(t, 5.0, querySingleFloat(t, txCtx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.InDelta(t, 10.0, querySingleFloat(t, txCtx, client, + "SELECT aff_frozen_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.Equal(t, 0, querySingleInt(t, txCtx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID)) +} + +// TestAffiliateRepository_ListAffiliateRebateRecords_IncludesNonOrderAccruals 验证兑换码 +// 等非订单来源的返利出现在返利记录中,订单字段为空。 +func TestAffiliateRepository_ListAffiliateRebateRecords_IncludesNonOrderAccruals(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + txCtx := dbent.NewTxContext(ctx, tx) + client := tx.Client() + repo := NewAffiliateRepository(client, integrationDB) + + inviter := mustCreateAffiliateWithdrawUser(t, client, "inviter") + invitee := mustCreateAffiliateWithdrawUser(t, client, "invitee") + mustSeedAffiliateQuota(t, txCtx, client, inviter.ID, 0, 0, 0) + + applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 2, 0, nil) + require.NoError(t, err) + require.True(t, applied) + + records, total, err := repo.ListAffiliateRebateRecords(txCtx, service.AffiliateRecordFilter{ + Search: invitee.Email, + Page: 1, + PageSize: 20, + SortDesc: true, + }) + require.NoError(t, err) + require.EqualValues(t, 1, total) + require.Len(t, records, 1) + record := records[0] + require.Nil(t, record.OrderID) + require.Nil(t, record.OrderAmount) + require.Nil(t, record.PayAmount) + require.Empty(t, record.OutTradeNo) + require.Equal(t, inviter.ID, record.InviterID) + require.NotNil(t, record.InviteeID) + require.Equal(t, invitee.ID, *record.InviteeID) + require.InDelta(t, 2.0, record.RebateAmount, 1e-9) +} diff --git a/backend/internal/repository/affiliate_repo_test.go b/backend/internal/repository/affiliate_repo_test.go index ccb7bb3daf..b3221a1ce8 100644 --- a/backend/internal/repository/affiliate_repo_test.go +++ b/backend/internal/repository/affiliate_repo_test.go @@ -20,9 +20,34 @@ func TestAffiliateRecordQueriesUseLedgerAuditFields(t *testing.T) { require.NoError(t, err) content := string(source) - require.Contains(t, content, "JOIN payment_orders po ON po.id = ual.source_order_id") + require.Contains(t, content, "LEFT JOIN payment_orders po ON po.id = ual.source_order_id") require.Contains(t, content, "ual.amount::double precision") require.Contains(t, content, "ual.balance_after::double precision") require.NotContains(t, content, "parseAffiliateRebateAmount") require.NotContains(t, content, `"current_balance": "u.balance"`) } + +// TestAffiliateRebateRecordsQueryKeepsNonOrderAccruals 锁定返利记录列出全部 +// accrue 流水:订单与被邀请人均为 LEFT JOIN,且不按 source_order_id 过滤, +// 兑换码、管理员充值来源的返利才会出现在明细里。 +func TestAffiliateRebateRecordsQueryKeepsNonOrderAccruals(t *testing.T) { + source, err := os.ReadFile("affiliate_repo.go") + require.NoError(t, err) + content := string(source) + + require.Contains(t, content, "LEFT JOIN payment_orders po ON po.id = ual.source_order_id") + require.Contains(t, content, "LEFT JOIN users invitee ON invitee.id = ual.source_user_id") + require.NotContains(t, content, "\nJOIN payment_orders po ON po.id = ual.source_order_id") + require.NotContains(t, content, "\nJOIN users invitee ON invitee.id = ual.source_user_id") + require.NotContains(t, content, "AND ual.source_order_id IS NOT NULL") +} + +// TestAffiliateTransferRecordsQueryIncludesOfflineWithdrawals 锁定提取记录同时 +// 列出转入余额与线下提现两类额度流出。 +func TestAffiliateTransferRecordsQueryIncludesOfflineWithdrawals(t *testing.T) { + source, err := os.ReadFile("affiliate_repo.go") + require.NoError(t, err) + content := string(source) + + require.Contains(t, content, "WHERE ual.action IN ('transfer', 'withdraw')") +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 405a5f343f..b3f6b602e6 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -820,6 +820,7 @@ func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) { users.GET("/lookup", h.Admin.Affiliate.LookupUsers) users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate) users.GET("/:user_id/overview", h.Admin.Affiliate.GetUserOverview) + users.POST("/:user_id/withdraw", h.Admin.Affiliate.WithdrawQuota) users.PUT("/:user_id", h.Admin.Affiliate.UpdateUserSettings) users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings) } diff --git a/backend/internal/service/affiliate_service.go b/backend/internal/service/affiliate_service.go index 91cca5e216..2c6423c787 100644 --- a/backend/internal/service/affiliate_service.go +++ b/backend/internal/service/affiliate_service.go @@ -17,6 +17,9 @@ var ( ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use") ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound") ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer") + + ErrAffiliateQuotaInsufficient = infraerrors.BadRequest("AFFILIATE_QUOTA_INSUFFICIENT", "insufficient available affiliate quota") + ErrAffiliateWithdrawAmountInvalid = infraerrors.BadRequest("AFFILIATE_WITHDRAW_AMOUNT_INVALID", "invalid offline withdrawal amount") ) const ( @@ -102,6 +105,7 @@ type AffiliateRepository interface { GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error) ThawFrozenQuota(ctx context.Context, userID int64) (float64, error) TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error) + WithdrawQuota(ctx context.Context, userID int64, amount float64) (*AffiliateWithdrawResult, error) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error) // 管理端:用户级专属配置 @@ -156,25 +160,31 @@ type AffiliateInviteRecord struct { CreatedAt time.Time `json:"created_at"` } +// AffiliateRebateRecord 是一笔返利入账流水。非订单来源(兑换码、管理员充值) +// 的流水没有订单信息,OrderID/OrderAmount/PayAmount 为 nil;被邀请人账号被删除时 +// InviteeID 为 nil。 type AffiliateRebateRecord struct { - OrderID int64 `json:"order_id"` + OrderID *int64 `json:"order_id"` OutTradeNo string `json:"out_trade_no"` InviterID int64 `json:"inviter_id"` InviterEmail string `json:"inviter_email"` InviterUsername string `json:"inviter_username"` - InviteeID int64 `json:"invitee_id"` + InviteeID *int64 `json:"invitee_id"` InviteeEmail string `json:"invitee_email"` InviteeUsername string `json:"invitee_username"` - OrderAmount float64 `json:"order_amount"` - PayAmount float64 `json:"pay_amount"` + OrderAmount *float64 `json:"order_amount"` + PayAmount *float64 `json:"pay_amount"` RebateAmount float64 `json:"rebate_amount"` PaymentType string `json:"payment_type"` OrderStatus string `json:"order_status"` CreatedAt time.Time `json:"created_at"` } +// AffiliateTransferRecord 是一笔返利额度流出:Action 为 transfer(用户转入余额) +// 或 withdraw(管理员登记的线下提现)。 type AffiliateTransferRecord struct { LedgerID int64 `json:"ledger_id"` + Action string `json:"action"` UserID int64 `json:"user_id"` UserEmail string `json:"user_email"` Username string `json:"username"` @@ -191,6 +201,16 @@ type AffiliateTransferRecord struct { CreatedAt time.Time `json:"created_at"` } +// AffiliateWithdrawResult 是线下提现登记完成后的流水 ID 与额度快照。 +type AffiliateWithdrawResult struct { + LedgerID int64 `json:"ledger_id"` + UserID int64 `json:"user_id"` + Amount float64 `json:"amount"` + AvailableQuotaAfter float64 `json:"available_quota_after"` + FrozenQuotaAfter float64 `json:"frozen_quota_after"` + HistoryQuotaAfter float64 `json:"history_quota_after"` +} + type AffiliateUserOverview struct { UserID int64 `json:"user_id"` Email string `json:"email"` @@ -559,6 +579,26 @@ func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, user return s.repo.BatchSetUserRebateRate(ctx, cleaned, ratePercent) } +// AdminWithdrawQuota 登记一笔已在站外打款的线下提现:从用户可提取返利额度 +// 中扣除 amount,并写入 action=withdraw 的流水。冻结期内的额度不可扣除。 +// 金额按返利流水精度保留 8 位小数,舍入后必须为正。 +func (s *AffiliateService) AdminWithdrawQuota(ctx context.Context, userID int64, amount float64) (*AffiliateWithdrawResult, error) { + if userID <= 0 { + return nil, infraerrors.BadRequest("INVALID_USER", "invalid user") + } + if s == nil || s.repo == nil { + return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable") + } + if math.IsNaN(amount) || math.IsInf(amount, 0) { + return nil, ErrAffiliateWithdrawAmountInvalid + } + amount = roundTo(amount, 8) + if amount <= 0 || math.IsInf(amount, 0) { + return nil, ErrAffiliateWithdrawAmountInvalid + } + return s.repo.WithdrawQuota(ctx, userID, amount) +} + // AdminListCustomUsers 列出有专属配置的用户。 func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) { if s == nil || s.repo == nil { diff --git a/backend/internal/service/affiliate_service_test.go b/backend/internal/service/affiliate_service_test.go index c02a4dd711..1e3f0b6c59 100644 --- a/backend/internal/service/affiliate_service_test.go +++ b/backend/internal/service/affiliate_service_test.go @@ -129,3 +129,67 @@ func TestIsValidAffiliateCodeFormat(t *testing.T) { }) } } + +type affiliateWithdrawCall struct { + userID int64 + amount float64 +} + +type affiliateWithdrawRepoStub struct { + AffiliateRepository + calls []affiliateWithdrawCall + result *AffiliateWithdrawResult + err error +} + +func (s *affiliateWithdrawRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64) (*AffiliateWithdrawResult, error) { + s.calls = append(s.calls, affiliateWithdrawCall{userID: userID, amount: amount}) + return s.result, s.err +} + +// TestAdminWithdrawQuota_RejectsInvalidAmount 覆盖线下提现金额校验:非正数、 +// NaN/Inf、舍入到 8 位小数后为 0 或溢出的金额都在服务层拒绝,不进入仓储。 +func TestAdminWithdrawQuota_RejectsInvalidAmount(t *testing.T) { + t.Parallel() + for _, amount := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1), 1e-9, math.MaxFloat64} { + repo := &affiliateWithdrawRepoStub{} + svc := &AffiliateService{repo: repo} + _, err := svc.AdminWithdrawQuota(context.Background(), 1, amount) + require.ErrorIs(t, err, ErrAffiliateWithdrawAmountInvalid, "amount %v", amount) + require.Empty(t, repo.calls, "amount %v must not reach repository", amount) + } +} + +// TestAdminWithdrawQuota_RejectsInvalidUser 验证缺少目标用户时直接拒绝。 +func TestAdminWithdrawQuota_RejectsInvalidUser(t *testing.T) { + t.Parallel() + repo := &affiliateWithdrawRepoStub{} + svc := &AffiliateService{repo: repo} + _, err := svc.AdminWithdrawQuota(context.Background(), 0, 1) + require.Error(t, err) + require.Empty(t, repo.calls) +} + +// TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision 验证金额按 8 位小数舍入后 +// 交给仓储,仓储结果原样返回。 +func TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision(t *testing.T) { + t.Parallel() + want := &AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891} + repo := &affiliateWithdrawRepoStub{result: want} + svc := &AffiliateService{repo: repo} + + got, err := svc.AdminWithdrawQuota(context.Background(), 42, 12.3456789149) + require.NoError(t, err) + require.Same(t, want, got) + require.Equal(t, []affiliateWithdrawCall{{userID: 42, amount: 12.34567891}}, repo.calls) +} + +// TestAdminWithdrawQuota_PropagatesInsufficientQuota 验证额度不足由仓储判定, +// 服务层原样返回,前端据错误码提示。 +func TestAdminWithdrawQuota_PropagatesInsufficientQuota(t *testing.T) { + t.Parallel() + repo := &affiliateWithdrawRepoStub{err: ErrAffiliateQuotaInsufficient} + svc := &AffiliateService{repo: repo} + _, err := svc.AdminWithdrawQuota(context.Background(), 1, 1) + require.ErrorIs(t, err, ErrAffiliateQuotaInsufficient) +} diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index b1366c3c96..f604e811f9 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -150,6 +150,10 @@ func (r *paymentFulfillmentAffiliateRepoStub) TransferQuotaToBalance(context.Con panic("unexpected TransferQuotaToBalance call") } +func (r *paymentFulfillmentAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64) (*AffiliateWithdrawResult, error) { + panic("unexpected WithdrawQuota call") +} + func (r *paymentFulfillmentAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]AffiliateInvitee, error) { panic("unexpected ListInvitees call") } diff --git a/frontend/src/api/admin/affiliates.ts b/frontend/src/api/admin/affiliates.ts index dadb0ae9e5..8cc736bce7 100644 --- a/frontend/src/api/admin/affiliates.ts +++ b/frontend/src/api/admin/affiliates.ts @@ -46,25 +46,35 @@ export interface AffiliateInviteRecord { created_at: string } +/** + * One rebate accrual. Non-order sources (redeem codes, admin recharges) carry no + * order, so order fields are null; invitee_id is null when the invitee account + * was deleted. + */ export interface AffiliateRebateRecord { - order_id: number + order_id: number | null out_trade_no: string inviter_id: number inviter_email: string inviter_username: string - invitee_id: number + invitee_id: number | null invitee_email: string invitee_username: string - order_amount: number - pay_amount: number + order_amount: number | null + pay_amount: number | null rebate_amount: number payment_type: string order_status: string created_at: string } +/** transfer = user moved quota into balance; withdraw = admin-recorded offline withdrawal. */ +export type AffiliateOutflowAction = 'transfer' | 'withdraw' + +/** One affiliate quota outflow: a transfer into balance or an offline withdrawal. */ export interface AffiliateTransferRecord { ledger_id: number + action: AffiliateOutflowAction user_id: number user_email: string username: string @@ -77,6 +87,20 @@ export interface AffiliateTransferRecord { created_at: string } +export interface WithdrawAffiliateQuotaRequest { + /** Amount already paid to the user outside the site (USD). */ + amount: number +} + +export interface AffiliateWithdrawResult { + ledger_id: number + user_id: number + amount: number + available_quota_after: number + frozen_quota_after: number + history_quota_after: number +} + export interface AffiliateUserOverview { user_id: number email: string @@ -215,6 +239,17 @@ export async function getUserOverview( return data } +export async function withdrawUserQuota( + userId: number, + payload: WithdrawAffiliateQuotaRequest, +): Promise { + const { data } = await apiClient.post( + `/admin/affiliates/users/${userId}/withdraw`, + payload, + ) + return data +} + export const affiliatesAPI = { listUsers, lookupUsers, @@ -225,6 +260,7 @@ export const affiliatesAPI = { listRebateRecords, listTransferRecords, getUserOverview, + withdrawUserQuota, } export default affiliatesAPI diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 725cdada28..51bd13a6ac 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -390,10 +390,12 @@ export default { affiliates: { invitesDescription: 'View site-wide inviter and invitee relationships', - rebatesDescription: 'View recharge orders that generated affiliate rebates', - transfersDescription: 'View affiliate quota transfers into account balance', + rebatesDescription: 'View every affiliate rebate accrual from recharge orders, redeem codes, and admin recharges', + transfersDescription: 'View affiliate quota transfers into balance and offline withdrawals', errors: { - loadFailed: 'Failed to load affiliate records' + loadFailed: 'Failed to load affiliate records', + AFFILIATE_QUOTA_INSUFFICIENT: 'Insufficient available affiliate quota', + AFFILIATE_WITHDRAW_AMOUNT_INVALID: 'Invalid withdrawal amount' }, records: { search: 'Search', @@ -418,7 +420,31 @@ export default { historyQuotaAfter: 'Historical Rebate After', invitedAt: 'Invited At', rebatedAt: 'Rebated At', - transferredAt: 'Transferred At' + transferredAt: 'Transferred At', + outflowType: 'Type' + }, + outflowTypes: { + transfer: 'To Balance', + withdraw: 'Offline Withdrawal' + }, + withdraw: { + button: 'Record Offline Withdrawal', + title: 'Record Offline Withdrawal', + user: 'User', + userPlaceholder: 'Search by email or username', + noUserFound: 'No matching users', + changeUser: 'Change user', + availableQuota: 'Available quota', + frozenHint: 'Rebates still in the freeze period are not included in the available quota', + amount: 'Withdrawal amount (USD)', + amountHint: 'Enter the amount already paid to this user outside the site', + fillAll: 'All', + warning: 'Recording deducts this amount from the user\'s available affiliate quota and cannot be undone. Make sure the off-site payment is complete.', + submit: 'Confirm', + submitting: 'Recording...', + success: 'Recorded offline withdrawal of {amount}; {remaining} still available', + amountRequired: 'Enter an amount greater than 0', + amountExceeds: 'Amount cannot exceed the available quota' }, overview: { title: 'Affiliate User Overview', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index b3b3e706d2..abc2b0c8d7 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -390,10 +390,12 @@ export default { affiliates: { invitesDescription: '查看全站邀请关系和被邀请用户累计返利', - rebatesDescription: '查看每一笔产生返利的充值订单', - transfersDescription: '查看返利额度转入账户余额的提取流水', + rebatesDescription: '查看每一笔返利入账,包括充值订单、兑换码和管理员充值产生的返利', + transfersDescription: '查看返利额度转入余额与线下提现的流水', errors: { - loadFailed: '加载邀请返利记录失败' + loadFailed: '加载邀请返利记录失败', + AFFILIATE_QUOTA_INSUFFICIENT: '可提取返利额度不足', + AFFILIATE_WITHDRAW_AMOUNT_INVALID: '提现金额无效' }, records: { search: '搜索', @@ -418,7 +420,31 @@ export default { historyQuotaAfter: '提取后历史返利', invitedAt: '邀请时间', rebatedAt: '返利时间', - transferredAt: '提取时间' + transferredAt: '提取时间', + outflowType: '类型' + }, + outflowTypes: { + transfer: '转入余额', + withdraw: '线下提现' + }, + withdraw: { + button: '登记线下提现', + title: '登记线下提现', + user: '用户', + userPlaceholder: '输入邮箱或用户名搜索', + noUserFound: '未找到匹配的用户', + changeUser: '更换用户', + availableQuota: '当前可提取额度', + frozenHint: '冻结期内的返利不计入可提取额度', + amount: '提现金额(USD)', + amountHint: '填写已在站外实际打款给该用户的金额', + fillAll: '全部', + warning: '登记后将从该用户的可提取返利额度中扣除,且无法撤销。请确认已完成站外打款。', + submit: '确认登记', + submitting: '登记中...', + success: '已登记线下提现 {amount},剩余可提取 {remaining}', + amountRequired: '请输入大于 0 的金额', + amountExceeds: '提现金额不能超过当前可提取额度' }, overview: { title: '用户返利概览', diff --git a/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue b/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue index 789df41a0d..ce105853fe 100644 --- a/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue +++ b/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue @@ -12,6 +12,15 @@ + @@ -36,7 +45,9 @@ /> + @@ -113,6 +133,13 @@ + + (null) +const withdrawDialog = ref(false) let debounceTimer: ReturnType | null = null const columns = computed(() => { @@ -201,6 +230,7 @@ const columns = computed(() => { } return [ { key: 'user', label: t('admin.affiliates.records.user'), sortable: true }, + { key: 'action', label: t('admin.affiliates.records.outflowType'), sortable: true }, { key: 'amount', label: t('admin.affiliates.records.transferAmount'), sortable: true }, { key: 'balance_after', label: t('admin.affiliates.records.balanceAfter'), sortable: true }, { key: 'available_quota_after', label: t('admin.affiliates.records.availableQuotaAfter'), sortable: true }, @@ -303,6 +333,17 @@ function handleSort(key: string, order: 'asc' | 'desc') { void loadRecords() } +function handleWithdrawSuccess() { + withdrawDialog.value = false + reloadFromFirstPage() +} + +function outflowTypeLabel(action: string | null | undefined): string { + return action === 'withdraw' + ? t('admin.affiliates.outflowTypes.withdraw') + : t('admin.affiliates.outflowTypes.transfer') +} + function formatAmount(value: number | null | undefined): string { return Number(value || 0).toFixed(2) } diff --git a/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue b/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue new file mode 100644 index 0000000000..8d3cf67979 --- /dev/null +++ b/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue @@ -0,0 +1,323 @@ + + + diff --git a/frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts b/frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts new file mode 100644 index 0000000000..6306a5364b --- /dev/null +++ b/frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts @@ -0,0 +1,179 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import AdminAffiliateRecordsTable from '../AdminAffiliateRecordsTable.vue' + +const { listInviteRecords, listRebateRecords, listTransferRecords, getUserOverview } = vi.hoisted(() => ({ + listInviteRecords: vi.fn(), + listRebateRecords: vi.fn(), + listTransferRecords: vi.fn(), + getUserOverview: vi.fn(), +})) + +vi.mock('@/api/admin/affiliates', () => { + const affiliatesAPI = { listInviteRecords, listRebateRecords, listTransferRecords, getUserOverview } + return { affiliatesAPI, default: affiliatesAPI } +}) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn() }), +})) + +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key, + }), + } +}) + +const TablePageLayoutStub = { + template: '
', +} + +const DataTableStub = { + props: ['columns', 'data'], + template: ` + + + + +
+ +
+ `, +} + +const WithdrawDialogStub = { + name: 'AffiliateOfflineWithdrawDialog', + props: ['show'], + emits: ['close', 'success'], + template: '
', +} + +function mountTable(type: 'invites' | 'rebates' | 'transfers') { + return mount(AdminAffiliateRecordsTable, { + props: { type }, + global: { + stubs: { + AppLayout: { template: '
' }, + TablePageLayout: TablePageLayoutStub, + DataTable: DataTableStub, + Pagination: true, + Icon: true, + OrderStatusBadge: true, + BaseDialog: true, + AffiliateOfflineWithdrawDialog: WithdrawDialogStub, + }, + }, + }) +} + +function page(items: T[]) { + return { items, total: items.length, page: 1, page_size: 20, pages: 1 } +} + +describe('AdminAffiliateRecordsTable', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + it('renders non-order rebate accruals with empty order fields', async () => { + listRebateRecords.mockResolvedValue(page([ + { + order_id: null, + out_trade_no: '', + inviter_id: 1, + inviter_email: 'inviter@example.com', + inviter_username: 'inviter', + invitee_id: 2, + invitee_email: 'invitee@example.com', + invitee_username: 'invitee', + order_amount: null, + pay_amount: null, + rebate_amount: 2, + payment_type: '', + order_status: '', + created_at: '2026-09-17T08:00:00Z', + }, + ])) + + const wrapper = mountTable('rebates') + await flushPromises() + + const row = wrapper.get('[data-test="row-0"]') + expect(row.get('[data-test="cell-order"]').text()).toBe('-') + expect(row.get('[data-test="cell-order_amount"]').text()).toBe('-') + expect(row.get('[data-test="cell-pay_amount"]').text()).toBe('-') + expect(row.get('[data-test="cell-payment_type"]').text()).toBe('-') + expect(row.get('[data-test="cell-order_status"]').text()).toBe('-') + expect(row.get('[data-test="cell-rebate_amount"]').text()).toBe('$2.00') + expect(wrapper.find('[data-test="affiliate-withdraw-open"]').exists()).toBe(false) + }) + + it('lists offline withdrawals next to balance transfers with their type', async () => { + listTransferRecords.mockResolvedValue(page([ + { + ledger_id: 11, + action: 'withdraw', + user_id: 42, + user_email: 'inviter@example.com', + username: 'inviter', + amount: 12.5, + balance_after: 5.5, + available_quota_after: 7.5, + frozen_quota_after: 0, + history_quota_after: 30, + snapshot_available: true, + created_at: '2026-09-17T08:00:00Z', + }, + { + ledger_id: 10, + action: 'transfer', + user_id: 42, + user_email: 'inviter@example.com', + username: 'inviter', + amount: 3, + balance_after: 5.5, + available_quota_after: 20, + frozen_quota_after: 0, + history_quota_after: 30, + snapshot_available: true, + created_at: '2026-09-16T08:00:00Z', + }, + ])) + + const wrapper = mountTable('transfers') + await flushPromises() + + const withdrawRow = wrapper.get('[data-test="row-0"]') + expect(withdrawRow.get('[data-test="cell-action"]').text()).toBe('admin.affiliates.outflowTypes.withdraw') + expect(withdrawRow.get('[data-test="cell-amount"]').text()).toBe('$12.50') + + const transferRow = wrapper.get('[data-test="row-1"]') + expect(transferRow.get('[data-test="cell-action"]').text()).toBe('admin.affiliates.outflowTypes.transfer') + }) + + it('opens the offline withdrawal dialog and reloads the first page after recording', async () => { + listTransferRecords.mockResolvedValue(page([])) + + const wrapper = mountTable('transfers') + await flushPromises() + expect(listTransferRecords).toHaveBeenCalledTimes(1) + + const button = wrapper.get('[data-test="affiliate-withdraw-open"]') + expect(button.text()).toBe('admin.affiliates.withdraw.button') + await button.trigger('click') + expect(wrapper.get('[data-test="withdraw-dialog"]').attributes('data-show')).toBe('true') + + wrapper.findComponent(WithdrawDialogStub).vm.$emit('success', { ledger_id: 12 }) + await flushPromises() + + expect(wrapper.get('[data-test="withdraw-dialog"]').attributes('data-show')).toBe('false') + expect(listTransferRecords).toHaveBeenCalledTimes(2) + expect(listTransferRecords.mock.calls[1][0]).toMatchObject({ page: 1 }) + }) +}) diff --git a/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts b/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts new file mode 100644 index 0000000000..f6aac2570a --- /dev/null +++ b/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts @@ -0,0 +1,152 @@ +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import AffiliateOfflineWithdrawDialog from '../AffiliateOfflineWithdrawDialog.vue' + +const { lookupUsers, getUserOverview, withdrawUserQuota, showError, showSuccess } = vi.hoisted(() => ({ + lookupUsers: vi.fn(), + getUserOverview: vi.fn(), + withdrawUserQuota: vi.fn(), + showError: vi.fn(), + showSuccess: vi.fn(), +})) + +vi.mock('@/api/admin/affiliates', () => ({ + affiliatesAPI: { lookupUsers, getUserOverview, withdrawUserQuota }, +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ showError, showSuccess }), +})) + +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useI18n: () => ({ + t: (key: string, params?: Record) => + params ? `${key} ${JSON.stringify(params)}` : key, + }), + } +}) + +const BaseDialogStub = { + props: ['show', 'title'], + emits: ['close'], + template: '
', +} + +function mountDialog() { + return mount(AffiliateOfflineWithdrawDialog, { + props: { show: true }, + global: { stubs: { BaseDialog: BaseDialogStub } }, + }) +} + +async function pickUser(wrapper: VueWrapper, availableQuota: number) { + lookupUsers.mockResolvedValue([{ id: 42, email: 'inviter@example.com', username: 'inviter' }]) + getUserOverview.mockResolvedValue({ + user_id: 42, + email: 'inviter@example.com', + username: 'inviter', + aff_code: 'INVITER', + rebate_rate_percent: 20, + invited_count: 3, + rebated_invitee_count: 2, + available_quota: availableQuota, + history_quota: 50, + }) + await wrapper.get('[data-test="withdraw-user-search"]').setValue('inviter') + vi.advanceTimersByTime(300) + await flushPromises() + await wrapper.get('[data-test="withdraw-user-option"]').trigger('click') + await flushPromises() +} + +describe('AffiliateOfflineWithdrawDialog', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('records the exact available quota as the withdrawal amount', async () => { + const wrapper = mountDialog() + await pickUser(wrapper, 12.34567891) + + expect(lookupUsers).toHaveBeenCalledWith('inviter') + expect(getUserOverview).toHaveBeenCalledWith(42) + expect(wrapper.get('[data-test="withdraw-available-quota"]').text()).toBe('$12.34567891') + + await wrapper.get('[data-test="withdraw-fill-all"]').trigger('click') + expect((wrapper.get('[data-test="withdraw-amount"]').element as HTMLInputElement).value).toBe('12.34567891') + + withdrawUserQuota.mockResolvedValue({ + ledger_id: 9, + user_id: 42, + amount: 12.34567891, + available_quota_after: 0, + frozen_quota_after: 0, + history_quota_after: 50, + }) + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(withdrawUserQuota).toHaveBeenCalledWith(42, { amount: 12.34567891 }) + expect(showSuccess).toHaveBeenCalledWith( + 'admin.affiliates.withdraw.success {"amount":"$12.34567891","remaining":"$0.00"}', + ) + expect(wrapper.emitted('success')?.[0]?.[0]).toMatchObject({ ledger_id: 9 }) + }) + + it('blocks amounts above the available quota', async () => { + const wrapper = mountDialog() + await pickUser(wrapper, 10) + + await wrapper.get('[data-test="withdraw-amount"]').setValue('10.00000001') + + expect(wrapper.get('[data-test="withdraw-amount-error"]').text()).toBe('admin.affiliates.withdraw.amountExceeds') + expect(wrapper.get('[data-test="withdraw-submit"]').attributes('disabled')).toBeDefined() + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + expect(withdrawUserQuota).not.toHaveBeenCalled() + }) + + it('treats a user without an affiliate profile as having no available quota', async () => { + lookupUsers.mockResolvedValue([{ id: 7, email: 'fresh@example.com', username: 'fresh' }]) + getUserOverview.mockRejectedValue({ reason: 'USER_NOT_FOUND', message: 'user not found' }) + const wrapper = mountDialog() + + await wrapper.get('[data-test="withdraw-user-search"]').setValue('fresh') + vi.advanceTimersByTime(300) + await flushPromises() + await wrapper.get('[data-test="withdraw-user-option"]').trigger('click') + await flushPromises() + + expect(showError).not.toHaveBeenCalled() + expect(wrapper.get('[data-test="withdraw-selected-user"]').text()).toContain('fresh@example.com') + expect(wrapper.get('[data-test="withdraw-available-quota"]').text()).toBe('$0.00') + expect(wrapper.get('[data-test="withdraw-fill-all"]').attributes('disabled')).toBeDefined() + + await wrapper.get('[data-test="withdraw-amount"]').setValue('1') + expect(wrapper.get('[data-test="withdraw-amount-error"]').text()).toBe('admin.affiliates.withdraw.amountExceeds') + expect(wrapper.get('[data-test="withdraw-submit"]').attributes('disabled')).toBeDefined() + }) + + it('shows the API error and reloads the available quota when recording fails', async () => { + const wrapper = mountDialog() + await pickUser(wrapper, 10) + + withdrawUserQuota.mockRejectedValue({ reason: 'AFFILIATE_QUOTA_INSUFFICIENT', message: 'insufficient' }) + await wrapper.get('[data-test="withdraw-amount"]').setValue('5') + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(showError).toHaveBeenCalledWith('admin.affiliates.errors.AFFILIATE_QUOTA_INSUFFICIENT {}') + expect(getUserOverview).toHaveBeenCalledTimes(2) + expect(wrapper.emitted('success')).toBeUndefined() + }) +})