diff --git a/controller/audit.go b/controller/audit.go index bf488d84..99a91cef 100644 --- a/controller/audit.go +++ b/controller/audit.go @@ -33,6 +33,7 @@ var auditContentTemplates = map[string]string{ "option.reset_ratio": "Reset model ratio settings", "billing.reconciliation_blocking_policy_update": "Updated billing reconciliation default user-blocking policy to ${block_user_by_default}", "billing.reconciliation_review": "Reviewed billing settlement ${settlement_id} (block user: ${block_user})", + "billing.reconciliation_review_batch": "Reviewed and closed ${count} billing reconciliation alerts", "channel.create": "Created channel ${name} (type ${type}, count ${count})", "channel.update": "Updated channel ${name} (ID: ${id})", diff --git a/controller/smart_ops_alert.go b/controller/smart_ops_alert.go index 08bfdee5..e57e8d03 100644 --- a/controller/smart_ops_alert.go +++ b/controller/smart_ops_alert.go @@ -58,6 +58,59 @@ type billingSettlementReviewRequest struct { Note string `json:"note"` } +type billingSettlementBatchReviewRequest struct { + Items []model.BillingSettlementReviewTarget `json:"items"` +} + +// ReviewBillingSettlements closes a bounded set of current reconciliation +// alerts after the administrator acknowledges them. +func ReviewBillingSettlements(c *gin.Context) { + var request billingSettlementBatchReviewRequest + if err := common.DecodeJson(c.Request.Body, &request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid billing settlement batch review request", + }) + return + } + records, err := service.ReviewBillingSettlements(request.Items, c.GetInt("id")) + if err != nil { + status := http.StatusInternalServerError + message := "failed to review billing settlements" + switch { + case errors.Is(err, service.ErrInvalidBillingSettlementReconciliationReview): + status = http.StatusBadRequest + message = err.Error() + case errors.Is(err, model.ErrBillingSettlementReviewConflict): + status = http.StatusConflict + message = "one or more billing settlement alerts changed; refresh and review again" + } + if status == http.StatusInternalServerError { + common.SysError(fmt.Sprintf("failed to review billing settlements: %v", err)) + } + c.JSON(status, gin.H{"success": false, "message": message}) + return + } + settlementIDs := make([]int64, 0, len(records)) + targetUserIDs := make([]int, 0, len(records)) + for _, record := range records { + settlementIDs = append(settlementIDs, record.ID) + targetUserIDs = append(targetUserIDs, record.UserID) + } + recordManageAudit(c, "billing.reconciliation_review_batch", map[string]interface{}{ + "count": len(records), + "settlement_ids": settlementIDs, + "target_user_ids": targetUserIDs, + }) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "reviewed_count": len(records), + "settlement_ids": settlementIDs, + }, + }) +} + func ReviewBillingSettlement(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil || id <= 0 { diff --git a/controller/smart_ops_alert_test.go b/controller/smart_ops_alert_test.go index 8e368e18..3758ad82 100644 --- a/controller/smart_ops_alert_test.go +++ b/controller/smart_ops_alert_test.go @@ -114,3 +114,103 @@ func TestReviewBillingSettlementAuditRecordsActingAdministrator(t *testing.T) { assert.Equal(t, "billing.reconciliation_review", other.Op.Action) assert.EqualValues(t, owner.Id, other.Op.Params["target_user_id"]) } + +func TestReviewBillingSettlementsBatchClosesAlertsWithoutNotes(t *testing.T) { + gin.SetMode(gin.TestMode) + oldDB := model.DB + oldLogDB := model.LOG_DB + oldRedisEnabled := common.RedisEnabled + oldMemoryCacheEnabled := common.MemoryCacheEnabled + common.RedisEnabled = false + common.MemoryCacheEnabled = false + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.BillingSettlement{}, &model.Log{})) + model.DB = db + model.LOG_DB = db + t.Cleanup(func() { + if sqlDB, dbErr := db.DB(); dbErr == nil { + _ = sqlDB.Close() + } + model.DB = oldDB + model.LOG_DB = oldLogDB + common.RedisEnabled = oldRedisEnabled + common.MemoryCacheEnabled = oldMemoryCacheEnabled + }) + + administrator := model.User{ + Id: 7201, + Username: "billing-batch-review-admin", + AffCode: "billing-batch-review-admin-aff", + Role: common.RoleRootUser, + Status: common.UserStatusEnabled, + Group: "default", + } + owners := []model.User{ + {Id: 7202, Username: "billing-batch-owner-a", AffCode: "billing-batch-owner-a-aff", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default"}, + {Id: 7203, Username: "billing-batch-owner-b", AffCode: "billing-batch-owner-b-aff", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default"}, + } + require.NoError(t, db.Create(&administrator).Error) + require.NoError(t, db.Create(&owners).Error) + settlements := []model.BillingSettlement{ + { + OperationKey: "request:controller-batch-review-a:finalize", Source: model.BillingSettlementSourceWallet, + UserID: owners[0].Id, FundingDelta: 10, TokenDelta: 10, + Status: model.BillingSettlementStatusPending, CreatedAt: 1, UpdatedAt: 2, Revision: 3, + }, + { + OperationKey: "request:controller-batch-review-b:finalize", Source: model.BillingSettlementSourceWallet, + UserID: owners[1].Id, FundingDelta: 11, TokenDelta: 11, + Status: model.BillingSettlementStatusManual, CreatedAt: 2, UpdatedAt: 3, Revision: 4, + }, + } + require.NoError(t, db.Create(&settlements).Error) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest( + http.MethodPost, + "/api/smart-ops/billing-settlements/reviews", + bytes.NewBufferString(fmt.Sprintf( + `{"items":[{"id":%d,"revision":%d},{"id":%d,"revision":%d}]}`, + settlements[0].ID, + settlements[0].Revision, + settlements[1].ID, + settlements[1].Revision, + )), + ) + ctx.Set("id", administrator.Id) + ctx.Set("username", administrator.Username) + ctx.Set("role", administrator.Role) + + ReviewBillingSettlements(ctx) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + var stored []model.BillingSettlement + require.NoError(t, db.Where("id IN ?", []int64{settlements[0].ID, settlements[1].ID}).Order("id ASC").Find(&stored).Error) + require.Len(t, stored, 2) + for index := range stored { + assert.Positive(t, stored[index].ReconciliationReviewedAt) + assert.Equal(t, administrator.Id, stored[index].ReconciliationReviewedBy) + assert.Empty(t, stored[index].ReconciliationReviewNote) + require.NotNil(t, stored[index].UserBlockingOverride) + assert.False(t, *stored[index].UserBlockingOverride) + assert.Equal(t, settlements[index].Status, stored[index].Status) + assert.Equal(t, settlements[index].UpdatedAt, stored[index].UpdatedAt) + assert.Equal(t, settlements[index].Revision, stored[index].Revision) + } + + var audit model.Log + require.NoError(t, db.Where("type = ?", model.LogTypeManage).Order("id DESC").First(&audit).Error) + var other struct { + Op struct { + Action string `json:"action"` + Params map[string]interface{} `json:"params"` + } `json:"op"` + } + require.NoError(t, common.UnmarshalJsonStr(audit.Other, &other)) + assert.Equal(t, "billing.reconciliation_review_batch", other.Op.Action) + assert.EqualValues(t, 2, other.Op.Params["count"]) +} diff --git a/model/billing_settlement.go b/model/billing_settlement.go index 93de9fb8..b9146e69 100644 --- a/model/billing_settlement.go +++ b/model/billing_settlement.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "sort" "sync" "time" @@ -185,6 +186,7 @@ type BillingSettlementBacklogStats struct { // other secret-bearing fields. type BillingSettlementReconciliationItem struct { ID int64 `json:"id"` + Revision int64 `json:"revision"` OperationKey string `json:"operation_key"` Status string `json:"status"` Source string `json:"source"` @@ -210,10 +212,18 @@ type BillingSettlementReconciliationItem struct { } type BillingSettlementReconciliationData struct { - TotalCount int64 `json:"total_count"` - PendingCount int64 `json:"pending_count"` - ManualCount int64 `json:"manual_count"` - OpenAlertCount int64 `json:"open_alert_count"` + TotalCount int64 `json:"total_count"` + // PendingCount and ManualCount classify financial states among currently + // open administrator alerts. Reviewed records are retained for durable + // financial recovery and leave this projection unless an administrator + // explicitly chose to keep the affected user blocked. + PendingCount int64 `json:"pending_count"` + ManualCount int64 `json:"manual_count"` + // OpenAlertCount is retained as an explicit operational label for clients; + // it equals TotalCount because this projection contains open alerts only. + OpenAlertCount int64 `json:"open_alert_count"` + // ReviewedCount is retained for API compatibility. Closed alerts are no + // longer part of this active projection, so the value is always zero. ReviewedCount int64 `json:"reviewed_count"` BlockingRecordCount int64 `json:"blocking_record_count"` BlockedUserCount int64 `json:"blocked_user_count"` @@ -233,11 +243,11 @@ func unresolvedPositiveFinalizeSettlementScope(db *gorm.DB) *gorm.DB { func openPositiveFinalizeSettlementAlertScope(db *gorm.DB) *gorm.DB { return unresolvedPositiveFinalizeSettlementScope(db). - Where("reconciliation_reviewed_at = ?", 0) + Where("(reconciliation_reviewed_at = ? OR user_blocking_override = ?)", 0, true) } func blockingPositiveFinalizeSettlementScope(db *gorm.DB, blockUserByDefault bool) *gorm.DB { - scope := unresolvedPositiveFinalizeSettlementScope(db) + scope := openPositiveFinalizeSettlementAlertScope(db) if blockUserByDefault { return scope.Where("(user_blocking_override IS NULL OR user_blocking_override = ?)", true) } @@ -337,9 +347,10 @@ func observeBillingSettlementBacklog(observedAt time.Time) { } // GetUnresolvedPositiveFinalizeSettlementStats returns the count and oldest -// creation time for open (not yet reviewed) positive request-finalize alerts. -// Reviewed rows remain unresolved financial records and are still returned by -// the reconciliation list until the settlement itself reaches applied. +// creation time for open positive request-finalize alerts. Reviewed rows +// remain durable financial records and leave this operational projection +// unless an administrator explicitly keeps the affected user blocked or new +// failure evidence reopens them. func GetUnresolvedPositiveFinalizeSettlementStats() (BillingSettlementBacklogStats, error) { if DB == nil { return BillingSettlementBacklogStats{}, errors.New("database is not initialized") @@ -348,8 +359,10 @@ func GetUnresolvedPositiveFinalizeSettlementStats() (BillingSettlementBacklogSta } // GetUnresolvedPositiveFinalizeSettlements returns bounded, read-only evidence -// for operators. It never retries, updates, deletes, or otherwise mutates a -// settlement record or any financial balance. +// for open operator alerts. Reviewed rows remain durable financial records and +// are excluded after closure unless an administrator explicitly keeps the +// affected user blocked. This function never retries, updates, deletes, or +// otherwise mutates a settlement record or any financial balance. func GetUnresolvedPositiveFinalizeSettlements(limit int) (BillingSettlementReconciliationData, error) { if DB == nil { return BillingSettlementReconciliationData{}, errors.New("database is not initialized") @@ -374,28 +387,25 @@ func GetUnresolvedPositiveFinalizeSettlements(limit int) (BillingSettlementRecon }) } err := DB.Transaction(func(tx *gorm.DB) error { - stats, err := getAllUnresolvedPositiveFinalizeSettlementStatsDB(tx) + stats, err := getOpenPositiveFinalizeSettlementAlertStatsDB(tx) if err != nil { return err } data.TotalCount = stats.Count data.OldestCreatedAt = stats.OldestCreatedAt - if err := unresolvedPositiveFinalizeSettlementScope(tx). + if err := openPositiveFinalizeSettlementAlertScope(tx). Where("status = ?", BillingSettlementStatusPending). Count(&data.PendingCount).Error; err != nil { return err } - if err := unresolvedPositiveFinalizeSettlementScope(tx). + if err := openPositiveFinalizeSettlementAlertScope(tx). Where("status = ?", BillingSettlementStatusManual). Count(&data.ManualCount).Error; err != nil { return err } - if err := openPositiveFinalizeSettlementAlertScope(tx). - Count(&data.OpenAlertCount).Error; err != nil { - return err - } - data.ReviewedCount = data.TotalCount - data.OpenAlertCount + data.OpenAlertCount = data.TotalCount + data.ReviewedCount = 0 blockingScope := blockingPositiveFinalizeSettlementScope(tx, data.BlockUserByDefault) if err := blockingScope.Count(&data.BlockingRecordCount).Error; err != nil { return err @@ -407,14 +417,13 @@ func GetUnresolvedPositiveFinalizeSettlements(limit int) (BillingSettlementRecon } items := make([]BillingSettlementReconciliationItem, 0, limit+1) - if err := unresolvedPositiveFinalizeSettlementScope(tx). + if err := openPositiveFinalizeSettlementAlertScope(tx). Select( - "id", "operation_key", "status", "source", "user_id", "subscription_id", "token_id", "task_id", + "id", "revision", "operation_key", "status", "source", "user_id", "subscription_id", "token_id", "task_id", "funding_delta", "applied_funding_delta", "token_delta", "applied_token_delta", "attempts", "last_error", "next_attempt", "created_at", "updated_at", "reconciliation_reviewed_at", "reconciliation_reviewed_by", "reconciliation_review_note", "user_blocking_override", ). - Order("reconciliation_reviewed_at ASC"). Order("created_at ASC"). Order("id ASC"). Limit(limit + 1). @@ -463,14 +472,6 @@ func GetUnresolvedPositiveFinalizeSettlements(limit int) (BillingSettlementRecon return data, err } -func getAllUnresolvedPositiveFinalizeSettlementStatsDB(db *gorm.DB) (BillingSettlementBacklogStats, error) { - var stats BillingSettlementBacklogStats - err := unresolvedPositiveFinalizeSettlementScope(db). - Select("COUNT(*) AS record_count, COALESCE(MIN(created_at), 0) AS oldest_created_at"). - Scan(&stats).Error - return stats, err -} - func getOpenPositiveFinalizeSettlementAlertStatsDB(db *gorm.DB) (BillingSettlementBacklogStats, error) { var stats BillingSettlementBacklogStats err := openPositiveFinalizeSettlementAlertScope(db). @@ -521,6 +522,69 @@ func billingSettlementReviewUpdates(reviewerID int, blockUser bool, note string, } } +// BillingSettlementReviewTarget identifies the exact financial snapshot an +// administrator intends to acknowledge and close. +type BillingSettlementReviewTarget struct { + ID int64 `json:"id"` + Revision int64 `json:"revision"` +} + +// ReviewBillingSettlements atomically acknowledges a bounded set of current +// alert snapshots. It records administrator review metadata and explicitly +// allows the affected records, but never changes settlement status, balances, +// applied deltas, effect state, updated_at, or financial revision. +func ReviewBillingSettlements(targets []BillingSettlementReviewTarget, reviewerID int) ([]BillingSettlement, error) { + if DB == nil { + return nil, errors.New("database is not initialized") + } + if reviewerID <= 0 || len(targets) == 0 || len(targets) > 200 { + return nil, ErrBillingSettlementReviewConflict + } + seen := make(map[int64]struct{}, len(targets)) + for _, target := range targets { + if target.ID <= 0 || target.Revision <= 0 { + return nil, ErrBillingSettlementReviewConflict + } + if _, exists := seen[target.ID]; exists { + return nil, ErrBillingSettlementReviewConflict + } + seen[target.ID] = struct{}{} + } + sortedTargets := append([]BillingSettlementReviewTarget(nil), targets...) + sort.Slice(sortedTargets, func(i, j int) bool { + return sortedTargets[i].ID < sortedTargets[j].ID + }) + + reviewed := make([]BillingSettlement, 0, len(targets)) + err := DB.Transaction(func(tx *gorm.DB) error { + reviewedAt := time.Now() + updatedIDs := make([]int64, 0, len(sortedTargets)) + for _, target := range sortedTargets { + result := openPositiveFinalizeSettlementAlertScope(tx). + Where("id = ? AND revision = ?", target.ID, target.Revision). + UpdateColumns(billingSettlementReviewUpdates(reviewerID, false, "", reviewedAt)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrBillingSettlementReviewConflict + } + updatedIDs = append(updatedIDs, target.ID) + } + if err := tx.Where("id IN ?", updatedIDs).Order("id ASC").Find(&reviewed).Error; err != nil { + return err + } + if len(reviewed) != len(updatedIDs) { + return gorm.ErrRecordNotFound + } + return nil + }) + if err != nil { + return nil, err + } + return reviewed, nil +} + func billingSettlementReviewMatches(record BillingSettlement, reviewerID int, blockUser bool, note string, reviewedAt int64) bool { return record.ReconciliationReviewedAt == reviewedAt && record.ReconciliationReviewedBy == reviewerID && diff --git a/model/billing_settlement_regression_test.go b/model/billing_settlement_regression_test.go index c0f679fd..e6abb931 100644 --- a/model/billing_settlement_regression_test.go +++ b/model/billing_settlement_regression_test.go @@ -233,32 +233,29 @@ func TestBillingSettlementBlockingPolicyAndReviewAreIndependentFromFinancialStat reconciliation, err := GetUnresolvedPositiveFinalizeSettlements(100) require.NoError(t, err) - assert.EqualValues(t, 5, reconciliation.TotalCount, "review does not complete or delete a settlement") + assert.EqualValues(t, 4, reconciliation.TotalCount, "reviewed settlements must leave the active alert projection") assert.EqualValues(t, 4, reconciliation.OpenAlertCount) - assert.EqualValues(t, 1, reconciliation.ReviewedCount) + assert.Zero(t, reconciliation.ReviewedCount) assert.EqualValues(t, 2, reconciliation.BlockingRecordCount) assert.EqualValues(t, 2, reconciliation.BlockedUserCount) assert.False(t, reconciliation.BlockUserByDefault) - require.Len(t, reconciliation.Items, 5) + require.Len(t, reconciliation.Items, 4) - var reviewedItem *BillingSettlementReconciliationItem var sharedAllowItem *BillingSettlementReconciliationItem var sharedBlockItem *BillingSettlementReconciliationItem for index := range reconciliation.Items { switch reconciliation.Items[index].ID { - case records[0].ID: - reviewedItem = &reconciliation.Items[index] case records[3].ID: sharedAllowItem = &reconciliation.Items[index] case records[4].ID: sharedBlockItem = &reconciliation.Items[index] } } - require.NotNil(t, reviewedItem) - assert.False(t, reviewedItem.RecordBlocksUser) - assert.False(t, reviewedItem.BlocksUser) - assert.Equal(t, 7001, reviewedItem.ReconciliationReviewedBy) - assert.Equal(t, "Verified provider usage against the invoice", reviewedItem.ReconciliationReviewNote) + var durableReviewed BillingSettlement + require.NoError(t, DB.First(&durableReviewed, records[0].ID).Error) + assert.Equal(t, BillingSettlementStatusPending, durableReviewed.Status) + assert.Equal(t, 7001, durableReviewed.ReconciliationReviewedBy) + assert.Equal(t, "Verified provider usage against the invoice", durableReviewed.ReconciliationReviewNote) require.NotNil(t, sharedAllowItem) assert.False(t, sharedAllowItem.RecordBlocksUser, "the row-level decision must remain allow") assert.True(t, sharedAllowItem.BlocksUser, "the user remains blocked by another unresolved record") @@ -267,6 +264,168 @@ func TestBillingSettlementBlockingPolicyAndReviewAreIndependentFromFinancialStat assert.True(t, sharedBlockItem.BlocksUser) } +func TestBillingSettlementReconciliationProjectsOnlyOpenAlerts(t *testing.T) { + setupUserUpdateTestState(t) + setBillingReconciliationBlockDefaultForTest(t, false) + + now := time.Now().Unix() + allow := false + records := []BillingSettlement{ + { + OperationKey: "request:open-pending-alert:finalize", Source: BillingSettlementSourceWallet, + UserID: 970, FundingDelta: 10, TokenDelta: 10, Status: BillingSettlementStatusPending, + CreatedAt: now - 30, UpdatedAt: now - 20, Revision: 2, + }, + { + OperationKey: "request:open-manual-alert:finalize", Source: BillingSettlementSourceWallet, + UserID: 971, FundingDelta: 11, TokenDelta: 11, Status: BillingSettlementStatusManual, + CreatedAt: now - 20, UpdatedAt: now - 10, Revision: 3, + }, + { + OperationKey: "request:closed-manual-alert:finalize", Source: BillingSettlementSourceWallet, + UserID: 972, FundingDelta: 12, TokenDelta: 12, Status: BillingSettlementStatusManual, + ReconciliationReviewedAt: now - 5, ReconciliationReviewedBy: 7001, + UserBlockingOverride: &allow, CreatedAt: now - 10, UpdatedAt: now - 5, Revision: 4, + }, + } + require.NoError(t, DB.Create(&records).Error) + + reconciliation, err := GetUnresolvedPositiveFinalizeSettlements(100) + + require.NoError(t, err) + assert.EqualValues(t, 2, reconciliation.TotalCount) + assert.EqualValues(t, 1, reconciliation.PendingCount) + assert.EqualValues(t, 1, reconciliation.ManualCount) + assert.EqualValues(t, 2, reconciliation.OpenAlertCount) + assert.Zero(t, reconciliation.ReviewedCount) + assert.Equal(t, records[0].CreatedAt, reconciliation.OldestCreatedAt) + require.Len(t, reconciliation.Items, 2) + assert.Equal(t, records[0].ID, reconciliation.Items[0].ID) + assert.Equal(t, records[0].Revision, reconciliation.Items[0].Revision) + assert.Equal(t, records[1].ID, reconciliation.Items[1].ID) + assert.Equal(t, records[1].Revision, reconciliation.Items[1].Revision) +} + +func TestReviewBillingSettlementsClosesSelectionAtomically(t *testing.T) { + setupUserUpdateTestState(t) + + now := time.Now().Unix() + records := []BillingSettlement{ + { + OperationKey: "request:batch-review-first:finalize", Source: BillingSettlementSourceWallet, + UserID: 973, FundingDelta: 10, AppliedFundingDelta: 2, TokenDelta: 10, AppliedTokenDelta: 2, + Status: BillingSettlementStatusPending, CreatedAt: now - 20, UpdatedAt: now - 10, Revision: 2, + }, + { + OperationKey: "request:batch-review-second:finalize", Source: BillingSettlementSourceWallet, + UserID: 974, FundingDelta: 11, TokenDelta: 11, Status: BillingSettlementStatusManual, + CreatedAt: now - 10, UpdatedAt: now - 5, Revision: 3, + }, + } + require.NoError(t, DB.Create(&records).Error) + queryCount := 0 + callbackName := "test:batch-review-single-read" + require.NoError(t, DB.Callback().Query().Before("gorm:query").Register(callbackName, func(_ *gorm.DB) { + queryCount++ + })) + t.Cleanup(func() { _ = DB.Callback().Query().Remove(callbackName) }) + + targets := []BillingSettlementReviewTarget{ + {ID: records[1].ID, Revision: records[1].Revision}, + {ID: records[0].ID, Revision: records[0].Revision}, + } + reviewed, err := ReviewBillingSettlements(targets, 7002) + + require.NoError(t, err) + require.Len(t, reviewed, 2) + assert.Equal(t, 1, queryCount, "reviewed settlements must be read back in one query") + assert.Equal(t, records[1].ID, targets[0].ID, "sorting must not mutate the caller's target order") + assert.Equal(t, records[0].ID, targets[1].ID) + for index := range reviewed { + assert.Equal(t, records[index].ID, reviewed[index].ID, "settlement locks must be acquired in ascending ID order") + assert.Positive(t, reviewed[index].ReconciliationReviewedAt) + assert.Equal(t, 7002, reviewed[index].ReconciliationReviewedBy) + assert.Empty(t, reviewed[index].ReconciliationReviewNote) + require.NotNil(t, reviewed[index].UserBlockingOverride) + assert.False(t, *reviewed[index].UserBlockingOverride) + assert.Equal(t, records[index].Status, reviewed[index].Status) + assert.Equal(t, records[index].FundingDelta, reviewed[index].FundingDelta) + assert.Equal(t, records[index].AppliedFundingDelta, reviewed[index].AppliedFundingDelta) + assert.Equal(t, records[index].TokenDelta, reviewed[index].TokenDelta) + assert.Equal(t, records[index].AppliedTokenDelta, reviewed[index].AppliedTokenDelta) + assert.Equal(t, records[index].UpdatedAt, reviewed[index].UpdatedAt) + assert.Equal(t, records[index].Revision, reviewed[index].Revision) + } + + reconciliation, err := GetUnresolvedPositiveFinalizeSettlements(100) + require.NoError(t, err) + assert.Zero(t, reconciliation.TotalCount) + assert.Empty(t, reconciliation.Items) +} + +func TestReviewBillingSettlementsRejectsStaleBatchWithoutPartialClose(t *testing.T) { + setupUserUpdateTestState(t) + + now := time.Now().Unix() + records := []BillingSettlement{ + { + OperationKey: "request:batch-review-current:finalize", Source: BillingSettlementSourceWallet, + UserID: 975, FundingDelta: 10, TokenDelta: 10, Status: BillingSettlementStatusPending, + CreatedAt: now - 20, UpdatedAt: now - 10, Revision: 2, + }, + { + OperationKey: "request:batch-review-stale:finalize", Source: BillingSettlementSourceWallet, + UserID: 976, FundingDelta: 11, TokenDelta: 11, Status: BillingSettlementStatusManual, + CreatedAt: now - 10, UpdatedAt: now - 5, Revision: 3, + }, + } + require.NoError(t, DB.Create(&records).Error) + + _, err := ReviewBillingSettlements([]BillingSettlementReviewTarget{ + {ID: records[0].ID, Revision: records[0].Revision}, + {ID: records[1].ID, Revision: records[1].Revision - 1}, + }, 7003) + + assert.ErrorIs(t, err, ErrBillingSettlementReviewConflict) + var stored []BillingSettlement + require.NoError(t, DB.Where("id IN ?", []int64{records[0].ID, records[1].ID}).Order("id ASC").Find(&stored).Error) + require.Len(t, stored, 2) + for _, record := range stored { + assert.Zero(t, record.ReconciliationReviewedAt) + assert.Zero(t, record.ReconciliationReviewedBy) + assert.Nil(t, record.UserBlockingOverride) + } +} + +func TestReviewBillingSettlementsRollsBackWhenBatchReadFails(t *testing.T) { + setupUserUpdateTestState(t) + + now := time.Now().Unix() + record := BillingSettlement{ + OperationKey: "request:batch-review-read-failure:finalize", Source: BillingSettlementSourceWallet, + UserID: 977, FundingDelta: 10, TokenDelta: 10, Status: BillingSettlementStatusPending, + CreatedAt: now - 10, UpdatedAt: now - 5, Revision: 2, + } + require.NoError(t, DB.Create(&record).Error) + + callbackName := "test:batch-review-read-failure" + readErr := errors.New("forced batch review read failure") + require.NoError(t, DB.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) { + _ = tx.AddError(readErr) + })) + _, err := ReviewBillingSettlements([]BillingSettlementReviewTarget{{ + ID: record.ID, Revision: record.Revision, + }}, 7004) + require.NoError(t, DB.Callback().Query().Remove(callbackName)) + + assert.ErrorIs(t, err, readErr) + var stored BillingSettlement + require.NoError(t, DB.First(&stored, record.ID).Error) + assert.Zero(t, stored.ReconciliationReviewedAt) + assert.Zero(t, stored.ReconciliationReviewedBy) + assert.Nil(t, stored.UserBlockingOverride) +} + func TestBillingSettlementBlockingScopeDoesNotLeakAcrossUsers(t *testing.T) { setupUserUpdateTestState(t) setBillingReconciliationBlockDefaultForTest(t, true) @@ -448,6 +607,7 @@ func TestReviewBillingSettlementRejectsStaleFailureEvidence(t *testing.T) { func TestReviewBillingSettlementAllowsEditingCurrentReview(t *testing.T) { setupUserUpdateTestState(t) + setBillingReconciliationBlockDefaultForTest(t, false) now := time.Now().Unix() allow := false @@ -468,6 +628,35 @@ func TestReviewBillingSettlementAllowsEditingCurrentReview(t *testing.T) { require.NotNil(t, reviewed.UserBlockingOverride) assert.True(t, *reviewed.UserBlockingOverride) assert.EqualValues(t, record.Revision, reviewed.Revision, "review metadata must remain independent from financial revision") + + stats, err := GetUnresolvedPositiveFinalizeSettlementStats() + require.NoError(t, err) + assert.EqualValues(t, 1, stats.Count, "a reviewed record explicitly kept blocking must remain an open alert") + blocked, err := HasUnresolvedPositiveFinalizeSettlement(record.UserID) + require.NoError(t, err) + assert.True(t, blocked, "the legacy continue-blocking disposition must still affect admission") + + reconciliation, err := GetUnresolvedPositiveFinalizeSettlements(100) + require.NoError(t, err) + require.Len(t, reconciliation.Items, 1) + assert.Equal(t, record.ID, reconciliation.Items[0].ID) + assert.True(t, reconciliation.Items[0].RecordBlocksUser) + + closed, err := ReviewBillingSettlements([]BillingSettlementReviewTarget{{ + ID: record.ID, + Revision: record.Revision, + }}, 7009) + require.NoError(t, err) + require.Len(t, closed, 1) + require.NotNil(t, closed[0].UserBlockingOverride) + assert.False(t, *closed[0].UserBlockingOverride) + + stats, err = GetUnresolvedPositiveFinalizeSettlementStats() + require.NoError(t, err) + assert.Zero(t, stats.Count, "one-click review must close a legacy continue-blocking alert") + blocked, err = HasUnresolvedPositiveFinalizeSettlement(record.UserID) + require.NoError(t, err) + assert.False(t, blocked) } func TestReviewBillingSettlementAcceptsLegacyNullReviewNote(t *testing.T) { diff --git a/router/api-router.go b/router/api-router.go index 0454d2b8..818696f2 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -236,6 +236,7 @@ func SetApiRouter(router *gin.Engine) { smartOpsRoute.GET("/alerts", controller.GetSmartOpsAlerts) smartOpsRoute.GET("/billing-settlements", middleware.DisableCache(), controller.GetBillingSettlementReconciliation) smartOpsRoute.PUT("/billing-settlements/blocking-policy", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.UpdateBillingSettlementBlockingPolicy) + smartOpsRoute.POST("/billing-settlements/reviews", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.ReviewBillingSettlements) smartOpsRoute.POST("/billing-settlements/:id/review", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.ReviewBillingSettlement) smartOpsRoute.GET("/channel-performance", controller.GetChannelPerformance) smartOpsRoute.GET("/channel-performance/detail", controller.GetChannelPerformanceDetail) diff --git a/service/billing_session_test.go b/service/billing_session_test.go index 632d0cca..184c0bc2 100644 --- a/service/billing_session_test.go +++ b/service/billing_session_test.go @@ -14,6 +14,7 @@ import ( "github.com/MAX-API-Next/MAX-API/i18n" "github.com/MAX-API-Next/MAX-API/model" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/setting/billing_reconciliation_setting" "github.com/MAX-API-Next/MAX-API/types" miniredis "github.com/alicebob/miniredis/v2" "github.com/gin-gonic/gin" @@ -40,6 +41,31 @@ type uncertainFundingSource struct { err error } +func setBillingReconciliationBlockDefaultForTest(t *testing.T, enabled bool) { + t.Helper() + key := billing_reconciliation_setting.OptionKeyBlockUserByDefault + common.OptionMapRWMutex.Lock() + if common.OptionMap == nil { + common.OptionMap = make(map[string]string) + } + original, existed := common.OptionMap[key] + if enabled { + common.OptionMap[key] = "true" + } else { + common.OptionMap[key] = "false" + } + common.OptionMapRWMutex.Unlock() + t.Cleanup(func() { + common.OptionMapRWMutex.Lock() + defer common.OptionMapRWMutex.Unlock() + if existed { + common.OptionMap[key] = original + } else { + delete(common.OptionMap, key) + } + }) +} + func isolateBillingSettlementBacklogState(t *testing.T) { t.Helper() @@ -879,6 +905,7 @@ func TestBillingSessionWalletPreConsumeUsesDurableRequestIdentity(t *testing.T) func TestNewBillingSessionRejectsUnresolvedPositiveFinalizeSettlement(t *testing.T) { require.NoError(t, i18n.Init()) truncate(t) + setBillingReconciliationBlockDefaultForTest(t, true) const userID, tokenID = 851, 852 seedUser(t, userID, 100) seedToken(t, tokenID, userID, "unresolved-finalize-token", 100) @@ -925,9 +952,48 @@ func TestNewBillingSessionRejectsUnresolvedPositiveFinalizeSettlement(t *testing assert.EqualValues(t, 100, getTokenRemainQuota(t, tokenID)) } +func TestNewBillingSessionAllowsUnresolvedPositiveFinalizeSettlementWhenPolicyIsDisabled(t *testing.T) { + truncate(t) + setBillingReconciliationBlockDefaultForTest(t, false) + const userID, tokenID = 855, 856 + seedUser(t, userID, 100) + seedToken(t, tokenID, userID, "allowed-unresolved-finalize-token", 100) + now := time.Now().Unix() + require.NoError(t, model.DB.Create(&model.BillingSettlement{ + OperationKey: "request:allowed-unresolved-finalize:finalize", + Source: model.BillingSettlementSourceWallet, + UserID: userID, + TokenID: tokenID, + FundingDelta: 10, + TokenDelta: 10, + Status: model.BillingSettlementStatusManual, + CreatedAt: now, + UpdatedAt: now, + Revision: 1, + }).Error) + + ctx, _ := gin.CreateTestContext(nil) + session, apiErr := NewBillingSession(ctx, &relaycommon.RelayInfo{ + RequestId: "allowed-after-unresolved-finalize", + UserId: userID, + TokenId: tokenID, + TokenKey: "allowed-unresolved-finalize-token", + OriginModelName: "allowed-finalize-model", + UserSetting: dto.UserSetting{ + BillingPreference: "wallet_only", + }, + }, 10) + + require.Nil(t, apiErr) + require.NotNil(t, session) + assert.EqualValues(t, 90, getUserQuota(t, userID)) + assert.EqualValues(t, 90, getTokenRemainQuota(t, tokenID)) +} + func TestInsufficientFinalizeBlocksFurtherBillingSessions(t *testing.T) { require.NoError(t, i18n.Init()) truncate(t) + setBillingReconciliationBlockDefaultForTest(t, true) const userID, tokenID = 853, 854 seedUser(t, userID, 15) seedToken(t, tokenID, userID, "insufficient-finalize-token", 100) diff --git a/service/smart_ops_alert.go b/service/smart_ops_alert.go index e0d7b2f4..64451ae2 100644 --- a/service/smart_ops_alert.go +++ b/service/smart_ops_alert.go @@ -340,8 +340,8 @@ func GetSmartOpsAlerts() []SmartOpsAlert { return alerts } -// GetBillingSettlementReconciliation returns bounded, read-only settlement -// evidence for administrators. It never retries or mutates financial state. +// GetBillingSettlementReconciliation returns bounded, read-only evidence for +// open administrator alerts. It never retries or mutates financial state. func GetBillingSettlementReconciliation(limit int) (model.BillingSettlementReconciliationData, error) { if limit == 0 { limit = 100 @@ -393,6 +393,30 @@ func ReviewBillingSettlement(id int64, reviewerID int, blockUser *bool, note str return record, nil } +// ReviewBillingSettlements validates and atomically closes current billing +// reconciliation alerts without changing their financial settlement state. +func ReviewBillingSettlements(targets []model.BillingSettlementReviewTarget, reviewerID int) ([]model.BillingSettlement, error) { + if reviewerID <= 0 || len(targets) == 0 || len(targets) > 200 { + return nil, ErrInvalidBillingSettlementReconciliationReview + } + seen := make(map[int64]struct{}, len(targets)) + for _, target := range targets { + if target.ID <= 0 || target.Revision <= 0 { + return nil, ErrInvalidBillingSettlementReconciliationReview + } + if _, exists := seen[target.ID]; exists { + return nil, ErrInvalidBillingSettlementReconciliationReview + } + seen[target.ID] = struct{}{} + } + records, err := model.ReviewBillingSettlements(targets, reviewerID) + if err != nil { + return nil, err + } + refreshBillingSettlementBacklogAfterReview() + return records, nil +} + type smartOpsAlertRecipient struct { ID int Email string diff --git a/service/smart_ops_alert_test.go b/service/smart_ops_alert_test.go index 77ccc860..2a104ec7 100644 --- a/service/smart_ops_alert_test.go +++ b/service/smart_ops_alert_test.go @@ -7,6 +7,7 @@ import ( "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/model" "github.com/stretchr/testify/require" ) @@ -167,6 +168,20 @@ func TestBillingSettlementReviewRequiresExplicitPolicyAndReason(t *testing.T) { require.ErrorIs(t, err, ErrInvalidBillingSettlementReconciliationReview) } +func TestBillingSettlementBatchReviewValidatesVersionedTargets(t *testing.T) { + _, err := ReviewBillingSettlements(nil, 2) + require.ErrorIs(t, err, ErrInvalidBillingSettlementReconciliationReview) + + _, err = ReviewBillingSettlements([]model.BillingSettlementReviewTarget{{ID: 1, Revision: 1}}, 0) + require.ErrorIs(t, err, ErrInvalidBillingSettlementReconciliationReview) + + _, err = ReviewBillingSettlements([]model.BillingSettlementReviewTarget{ + {ID: 1, Revision: 1}, + {ID: 1, Revision: 1}, + }, 2) + require.ErrorIs(t, err, ErrInvalidBillingSettlementReconciliationReview) +} + func TestSmartOpsAlertNotifyTypeSeparatesNodes(t *testing.T) { alert := SmartOpsAlert{Key: "system_cpu", Node: "node-a", Status: smartOpsAlertStatusFiring} nodeAType := smartOpsAlertNotifyType(alert) diff --git a/setting/billing_reconciliation_setting/config.go b/setting/billing_reconciliation_setting/config.go index b2d79d50..1a84792b 100644 --- a/setting/billing_reconciliation_setting/config.go +++ b/setting/billing_reconciliation_setting/config.go @@ -14,7 +14,7 @@ type BillingReconciliationSetting struct { } var billingReconciliationSetting = BillingReconciliationSetting{ - BlockUserByDefault: true, + BlockUserByDefault: false, } func init() { @@ -22,14 +22,15 @@ func init() { } // BlockUserByDefault returns the persisted admission policy. Reading through -// OptionMap keeps the hot path synchronized with runtime option updates while -// retaining a fail-closed default before options are initialized. +// OptionMap keeps the hot path synchronized with runtime option updates. New +// installations allow paid requests by default; malformed persisted values +// still fail closed instead of silently weakening an explicit policy. func BlockUserByDefault() bool { common.OptionMapRWMutex.RLock() raw, ok := common.OptionMap[OptionKeyBlockUserByDefault] common.OptionMapRWMutex.RUnlock() if !ok { - return true + return false } enabled, err := strconv.ParseBool(raw) if err != nil { diff --git a/setting/billing_reconciliation_setting/config_test.go b/setting/billing_reconciliation_setting/config_test.go index 374d79ef..b4a16227 100644 --- a/setting/billing_reconciliation_setting/config_test.go +++ b/setting/billing_reconciliation_setting/config_test.go @@ -8,22 +8,27 @@ import ( "github.com/stretchr/testify/assert" ) -func TestDefaultBlockingPolicyIsRegisteredFailClosed(t *testing.T) { +func TestDefaultBlockingPolicyAllowsPaidRequests(t *testing.T) { registered := config.GlobalConfig.ExportAllConfigs() - assert.Equal(t, "true", registered[OptionKeyBlockUserByDefault]) + assert.Equal(t, "false", registered[OptionKeyBlockUserByDefault]) restoreOptionMap := replaceOptionMapForTest(map[string]string{}) t.Cleanup(restoreOptionMap) - assert.True(t, BlockUserByDefault()) + assert.False(t, BlockUserByDefault()) } func TestBlockUserByDefaultParsesRuntimeOptionFailClosed(t *testing.T) { restoreOptionMap := replaceOptionMapForTest(map[string]string{ - OptionKeyBlockUserByDefault: "false", + OptionKeyBlockUserByDefault: "true", }) t.Cleanup(restoreOptionMap) + assert.True(t, BlockUserByDefault()) + + common.OptionMapRWMutex.Lock() + common.OptionMap[OptionKeyBlockUserByDefault] = "false" + common.OptionMapRWMutex.Unlock() assert.False(t, BlockUserByDefault()) common.OptionMapRWMutex.Lock() diff --git a/web/default/src/components/ui/checkbox.tsx b/web/default/src/components/ui/checkbox.tsx index 08dc587b..77f269a3 100644 --- a/web/default/src/components/ui/checkbox.tsx +++ b/web/default/src/components/ui/checkbox.tsx @@ -19,7 +19,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API 'use client' import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox' -import { Tick02Icon } from '@hugeicons/core-free-icons' +import { MinusSignIcon, Tick02Icon } from '@hugeicons/core-free-icons' import { HugeiconsIcon } from '@hugeicons/react' import { cn } from '@/lib/utils' @@ -28,7 +28,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { - + + ) diff --git a/web/default/src/features/smart-ops/alerts.test.tsx b/web/default/src/features/smart-ops/alerts.test.tsx index 2b242084..89646d4f 100644 --- a/web/default/src/features/smart-ops/alerts.test.tsx +++ b/web/default/src/features/smart-ops/alerts.test.tsx @@ -57,7 +57,6 @@ function emptyReconciliationData(): BillingSettlementReconciliationData { pending_count: 0, manual_count: 0, open_alert_count: 0, - reviewed_count: 0, blocking_record_count: 0, blocked_user_count: 0, block_user_by_default: true, @@ -175,11 +174,10 @@ describe('SmartOps active alerts', () => { data: { success: true, data: { - total_count: 48, - pending_count: 17, - manual_count: 31, - open_alert_count: 1, - reviewed_count: 47, + total_count: 24, + pending_count: 1, + manual_count: 23, + open_alert_count: 24, blocking_record_count: 1, blocked_user_count: 9, block_user_by_default: true, @@ -189,6 +187,7 @@ describe('SmartOps active alerts', () => { items: [ { id: 71, + revision: 3, operation_key: 'request:billing-request-71:finalize', status: 'manual', source: 'wallet', @@ -227,7 +226,7 @@ describe('SmartOps active alerts', () => { severity: 'warning', component: 'billing', node: 'XG', - current_value: 48, + current_value: 24, threshold: 2121911, observed_at: '2026-08-30T16:14:15Z', message: 'billing backlog', @@ -250,10 +249,14 @@ describe('SmartOps active alerts', () => { assert.ok(urls.includes('/api/smart-ops/alerts')) assert.ok(urls.includes('/api/smart-ops/billing-settlements')) assert.ok(text.includes('Billing reconciliation backlog')) - assert.ok(text.includes('48 records')) + assert.ok(text.includes('24 records')) assert.ok(!text.includes('4,800.0%')) - assert.ok(text.includes('Pending: 17')) - assert.ok(text.includes('Manual: 31')) + assert.ok(text.includes('Open alerts: 24')) + assert.ok(!text.includes('Reviewed records')) + assert.ok(text.includes('Open pending settlements: 1')) + assert.ok(text.includes('Open manual settlements: 23')) + assert.ok(!text.includes('Manual settlements: 50')) + assert.ok(!text.includes('Pending: 1')) assert.ok(text.includes('Blocked users: 9')) assert.ok(text.includes('request:billing-request-71:finalize')) assert.ok(text.includes('user quota is not enough')) @@ -263,36 +266,9 @@ describe('SmartOps active alerts', () => { const reviewButton = within(view.container).getByRole('button', { name: 'Review and close', }) - await view.click(reviewButton) - let dialog: HTMLElement | undefined - await waitFor(() => { - dialog = within(document.body).getByRole('dialog', { - name: 'Review billing reconciliation', - }) - }) - assert.ok(dialog) - const noteInput = within(dialog).getByRole('textbox', { - name: 'Review note', - }) - assert.equal(noteInput.getAttribute('aria-invalid'), 'true') - assert.ok( - (dialog.textContent ?? '').includes( - 'Review note must contain between 3 and 1000 characters.' - ) - ) - assert.equal( - within(dialog) - .getByRole('button', { name: 'Close alert' }) - .hasAttribute('disabled'), - true - ) - assert.equal( - within(dialog) - .getByRole('button', { name: 'Allow user to continue' }) - .getAttribute('aria-pressed'), - 'true' - ) - await view.click(within(dialog).getByRole('button', { name: 'Cancel' })) + assert.equal(reviewButton.hasAttribute('disabled'), false) + assert.equal(within(view.container).queryByRole('textbox'), null) + assert.equal(within(document.body).queryByRole('dialog'), null) } finally { api.get = originalGet queryClient.clear() @@ -384,7 +360,7 @@ describe('SmartOps active alerts', () => { } }) - test('loads reviewed reconciliation without an active alert and submits administrator controls', async (): Promise => { + test('batch closes selected active reconciliation alerts without requesting notes', async (): Promise => { const originalUser = useAuthStore.getState().auth.user useAuthStore.getState().auth.setUser({ id: 1, @@ -405,10 +381,10 @@ describe('SmartOps active alerts', () => { success: true, data: { ...emptyReconciliationData(), - total_count: 1, + total_count: 2, pending_count: 1, - open_alert_count: 0, - reviewed_count: 2, + manual_count: 1, + open_alert_count: 2, blocking_record_count: 0, blocked_user_count: 0, block_user_by_default: blockUserByDefault, @@ -431,12 +407,39 @@ describe('SmartOps active alerts', () => { next_attempt: 1788106500, created_at: 1786032544, updated_at: 1786032544, - reconciliation_reviewed_at: 1788106400, - reconciliation_reviewed_by: 7, - reconciliation_review_note: '🙂🙂🙂', - user_blocking_override: false, - record_blocks_user: false, - blocks_user: false, + revision: 4, + reconciliation_reviewed_at: 0, + reconciliation_reviewed_by: 0, + reconciliation_review_note: '', + user_blocking_override: null, + record_blocks_user: true, + blocks_user: true, + }, + { + id: 92, + operation_key: 'request:billing-request-92:finalize', + status: 'manual', + source: 'wallet', + user_id: 52, + subscription_id: 0, + token_id: 0, + task_id: 0, + funding_delta: 200, + applied_funding_delta: 0, + token_delta: 200, + applied_token_delta: 0, + attempts: 3, + last_error: 'manual reconciliation required', + next_attempt: 0, + created_at: 1786032545, + updated_at: 1786032545, + revision: 5, + reconciliation_reviewed_at: 0, + reconciliation_reviewed_by: 0, + reconciliation_review_note: '', + user_blocking_override: null, + record_blocks_user: true, + blocks_user: true, }, ], }, @@ -465,7 +468,7 @@ describe('SmartOps active alerts', () => { try { let policySwitch: HTMLElement | undefined - let reviewButton: HTMLElement | undefined + let closeSelectedButton: HTMLElement | undefined await waitFor(() => { const screen = within(view.container) assert.ok( @@ -477,13 +480,13 @@ describe('SmartOps active alerts', () => { ) ) assert.ok( - (view.container.textContent ?? '').includes('Reviewed records: 2') + !(view.container.textContent ?? '').includes('Reviewed records') ) policySwitch = screen.getByRole('switch', { name: 'Block affected users by default', }) - reviewButton = screen.getByRole('button', { - name: 'Edit review', + closeSelectedButton = screen.getByRole('button', { + name: 'Review and close selected (0)', }) }) @@ -498,36 +501,37 @@ describe('SmartOps active alerts', () => { assert.ok(reconciliationRequests > 1) }) - reviewButton = within(view.container).getByRole('button', { - name: 'Edit review', - }) - await view.click(reviewButton) - let dialog: HTMLElement | undefined - await waitFor(() => { - dialog = within(document.body).getByRole('dialog') + assert.ok(closeSelectedButton) + assert.equal(closeSelectedButton.hasAttribute('disabled'), true) + const firstRowSelection = within(view.container).getByRole('checkbox', { + name: 'Select billing reconciliation alert 91', }) - assert.ok(dialog) - const noteInput = within(dialog).getByRole('textbox', { - name: 'Review note', - }) as HTMLTextAreaElement - assert.equal(noteInput.value, '🙂🙂🙂') - assert.ok((dialog.textContent ?? '').includes('3 / 1000 characters')) - const closeAlertButton = within(dialog).getByRole('button', { - name: 'Save review', + await view.click(firstRowSelection) + const selectAll = within(view.container).getByRole('checkbox', { + name: 'Select all billing reconciliation alerts', }) - await waitFor(() => - assert.equal(closeAlertButton.hasAttribute('disabled'), false) + assert.equal(selectAll.hasAttribute('data-indeterminate'), true) + assert.ok( + selectAll.querySelector('[data-checkbox-indicator="indeterminate"]') ) + await view.click(selectAll) + closeSelectedButton = within(view.container).getByRole('button', { + name: 'Review and close selected (2)', + }) + assert.equal(closeSelectedButton.hasAttribute('disabled'), false) + assert.equal(within(view.container).queryByRole('textbox'), null) const reconciliationRequestsBeforeReview = reconciliationRequests - await view.click(closeAlertButton) + await view.click(closeSelectedButton) await waitFor(() => { assert.deepEqual(writes[1], { method: 'POST', - url: '/api/smart-ops/billing-settlements/91/review', + url: '/api/smart-ops/billing-settlements/reviews', data: { - block_user: false, - note: '🙂🙂🙂', + items: [ + { id: 91, revision: 4 }, + { id: 92, revision: 5 }, + ], }, }) assert.ok(reconciliationRequests > reconciliationRequestsBeforeReview) @@ -542,6 +546,127 @@ describe('SmartOps active alerts', () => { } }) + test('clears a selected alert when refresh changes its financial revision', async (): Promise => { + const originalGet = api.get + const originalPost = api.post + const writes: Array<{ url: string; data: unknown }> = [] + let revision = 4 + api.get = (async (url: string): Promise => { + if (url !== '/api/smart-ops/billing-settlements') { + return { data: { success: true, data: [] } } + } + return { + data: { + success: true, + data: { + ...emptyReconciliationData(), + total_count: 1, + pending_count: 1, + open_alert_count: 1, + items: [ + { + id: 91, + revision, + operation_key: 'request:billing-request-91:finalize', + status: 'pending', + source: 'wallet', + user_id: 51, + subscription_id: 0, + token_id: 0, + task_id: 0, + funding_delta: 100, + applied_funding_delta: 0, + token_delta: 100, + applied_token_delta: 0, + attempts: 2, + last_error: 'quota changed', + next_attempt: 1788106500, + created_at: 1786032544, + updated_at: 1786032544, + reconciliation_reviewed_at: 0, + reconciliation_reviewed_by: 0, + reconciliation_review_note: '', + user_blocking_override: null, + record_blocks_user: false, + blocks_user: false, + }, + ], + }, + }, + } + }) as typeof api.get + api.post = (async (url: string, data: unknown): Promise => { + writes.push({ url: String(url), data }) + return { data: { success: true } } + }) as typeof api.post + + const queryClient = createQueryClient() + const view = await testEnv.render( + + + + ) + + try { + let rowSelection: HTMLElement | undefined + await waitFor(() => { + rowSelection = within(view.container).getByRole('checkbox', { + name: 'Select billing reconciliation alert 91', + }) + }) + assert.ok(rowSelection) + await view.click(rowSelection) + await waitFor(() => { + assert.ok( + within(view.container).getByRole('button', { + name: 'Review and close selected (1)', + }) + ) + }) + + revision = 6 + await queryClient.invalidateQueries() + await waitFor(() => { + const closeSelectedButton = within(view.container).getByRole('button', { + name: 'Review and close selected (0)', + }) + assert.equal(closeSelectedButton.hasAttribute('disabled'), true) + assert.equal( + within(view.container) + .getByRole('checkbox', { + name: 'Select billing reconciliation alert 91', + }) + .getAttribute('data-checked'), + null + ) + }) + assert.equal(writes.length, 0) + + rowSelection = within(view.container).getByRole('checkbox', { + name: 'Select billing reconciliation alert 91', + }) + await view.click(rowSelection) + await view.click( + within(view.container).getByRole('button', { + name: 'Review and close selected (1)', + }) + ) + await waitFor(() => { + assert.deepEqual(writes, [ + { + url: '/api/smart-ops/billing-settlements/reviews', + data: { items: [{ id: 91, revision: 6 }] }, + }, + ]) + }) + } finally { + api.get = originalGet + api.post = originalPost + await view.unmount() + queryClient.clear() + } + }) + test('keeps the global blocking policy read only for non-root administrators', async (): Promise => { const originalUser = useAuthStore.getState().auth.user useAuthStore.getState().auth.setUser({ diff --git a/web/default/src/features/smart-ops/api.ts b/web/default/src/features/smart-ops/api.ts index 5083f117..b4a96566 100644 --- a/web/default/src/features/smart-ops/api.ts +++ b/web/default/src/features/smart-ops/api.ts @@ -58,12 +58,11 @@ export async function updateBillingSettlementBlockingPolicy( return response.data } -export async function reviewBillingSettlement( - id: number, +export async function reviewBillingSettlements( request: BillingSettlementReviewRequest ): Promise { const response = await api.post( - `/api/smart-ops/billing-settlements/${id}/review`, + '/api/smart-ops/billing-settlements/reviews', request, { skipBusinessError: true, skipErrorHandler: true } ) diff --git a/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx b/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx index 3089d7d6..2bbfefee 100644 --- a/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx +++ b/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx @@ -16,9 +16,9 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, type ReactElement } from 'react' +import { useMemo, useState, type ReactElement } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { CheckCircle2, TriangleAlert } from 'lucide-react' +import { CheckCircle2, Loader2, TriangleAlert } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { formatTimestampToDate } from '@/lib/format' @@ -34,7 +34,10 @@ import { } from '@/components/ui/field' import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' -import { updateBillingSettlementBlockingPolicy } from '../api' +import { + reviewBillingSettlements, + updateBillingSettlementBlockingPolicy, +} from '../api' import { formatCount, formatLocalizedCount } from '../lib/format' import { mutationErrorMessage } from '../lib/mutation-error' import { @@ -43,10 +46,9 @@ import { } from '../lib/query-keys' import type { BillingSettlementReconciliationData, - BillingSettlementReconciliationItem, + BillingSettlementReviewTarget, } from '../types' import { BillingSettlementTable } from './billing-settlement-table' -import { SettlementReviewDialog } from './settlement-review-dialog' interface BillingSettlementEvidenceProps { canUpdateBlockingPolicy: boolean @@ -60,12 +62,28 @@ export function BillingSettlementEvidence( props: BillingSettlementEvidenceProps ): ReactElement { const { t, i18n } = useTranslation() - const [selectedItem, setSelectedItem] = - useState(null) + const [selectedTargets, setSelectedTargets] = useState< + Map + >(() => new Map()) + const reconciliationItems = props.data?.items + const { activeSelectedTargets, activeSelectedTargetMap } = useMemo(() => { + const currentRevisions = new Map( + reconciliationItems?.map((item) => [item.id, item.revision]) ?? [] + ) + const targets = Array.from(selectedTargets.values()).filter( + (target) => currentRevisions.get(target.id) === target.revision + ) + return { + activeSelectedTargets: targets, + activeSelectedTargetMap: new Map( + targets.map((target) => [target.id, target]) + ), + } + }, [reconciliationItems, selectedTargets]) const [policyOverride, setPolicyOverride] = useState(null) const queryClient = useQueryClient() const policyValue = - policyOverride ?? props.data?.block_user_by_default ?? true + policyOverride ?? props.data?.block_user_by_default ?? false const policyMutation = useMutation({ mutationKey: ['smart-ops', 'billing-settlement-blocking-policy'], @@ -106,6 +124,57 @@ export function BillingSettlementEvidence( policyMutation.mutate(checked) } + const reviewMutation = useMutation({ + mutationKey: ['smart-ops', 'billing-settlement-reviews'], + mutationFn: async ( + targets: BillingSettlementReviewTarget[] + ): Promise => { + const response = await reviewBillingSettlements({ items: targets }) + if (!response.success) { + throw new Error( + response.message || t('Failed to close reconciliation alerts.') + ) + } + return targets.length + }, + onSuccess: async (count: number): Promise => { + setSelectedTargets(new Map()) + toast.success( + t('Billing reconciliation alerts closed: {{count}}', { count }) + ) + }, + onSettled: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: SMART_OPS_ACTIVE_ALERTS_QUERY_KEY, + }), + queryClient.invalidateQueries({ + queryKey: SMART_OPS_BILLING_RECONCILIATION_QUERY_KEY, + }), + ]) + }, + onError: (error) => { + handleServerError(error, { + fallback: mutationErrorMessage( + error, + t('Failed to close reconciliation alerts.') + ), + }) + }, + }) + + const reviewTargets = (targets: BillingSettlementReviewTarget[]): void => { + if (targets.length > 0 && !reviewMutation.isPending) { + reviewMutation.mutate(targets) + } + } + + const replaceSelectedTargets = ( + targets: Map + ): void => { + setSelectedTargets(targets) + } + if (props.loading) { return (
@@ -153,11 +222,11 @@ export function BillingSettlementEvidence( id='billing-reconciliation-heading' className='text-sm font-semibold' > - {t('Billing reconciliation controls')} + {t('Open billing reconciliation alerts')}

{t( - 'Review and close operational alerts without changing the underlying pending or manual financial settlement.' + 'Select one or more alerts and close them with one click. The underlying financial settlement record remains available for safe retry and audit.' )}

@@ -172,21 +241,12 @@ export function BillingSettlementEvidence( )} - {formatLocalizedCount( - props.data.reviewed_count, - i18n.language, - t, - 'Reviewed record: {{count}}', - 'Reviewed records: {{count}}' - )} - - - {t('Pending: {{count}}', { + {t('Open pending settlements: {{count}}', { count: formatCount(props.data.pending_count, i18n.language), })} - {t('Manual: {{count}}', { + {t('Open manual settlements: {{count}}', { count: formatCount(props.data.manual_count, i18n.language), })} @@ -230,25 +290,56 @@ export function BillingSettlementEvidence( {props.data.items.length === 0 ? ( ) : ( - +
+
+ + {t('Selected alerts: {{count}}', { + count: formatCount(activeSelectedTargets.length, i18n.language), + })} + + +
+ +
)} {props.data.truncated && ( {t( - 'Showing the oldest {{count}} records; the summary covers all {{total}} unresolved records.', + 'Showing the oldest {{count}} alerts; the summary covers all {{total}} open alerts.', { count: formatCount(props.data.items.length, i18n.language), total: formatCount(props.data.total_count, i18n.language), @@ -262,16 +353,6 @@ export function BillingSettlementEvidence( time: formatTimestampToDate(props.data.generated_at), })}

- {selectedItem && ( - { - if (!open) setSelectedItem(null) - }} - /> - )} ) } diff --git a/web/default/src/features/smart-ops/components/billing-settlement-table.tsx b/web/default/src/features/smart-ops/components/billing-settlement-table.tsx index 60014b8d..7ad5c40a 100644 --- a/web/default/src/features/smart-ops/components/billing-settlement-table.tsx +++ b/web/default/src/features/smart-ops/components/billing-settlement-table.tsx @@ -18,7 +18,6 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import type { ReactElement } from 'react' import type { TFunction } from 'i18next' -import { Pencil } from 'lucide-react' import { useTranslation } from 'react-i18next' import { formatQuota, @@ -27,6 +26,7 @@ import { } from '@/lib/format' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' import { Table, TableBody, @@ -35,8 +35,12 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import { useBillingSettlementSelection } from '../hooks/use-billing-settlement-selection' import { formatLocalizedCount } from '../lib/format' -import type { BillingSettlementReconciliationItem } from '../types' +import type { + BillingSettlementReconciliationItem, + BillingSettlementReviewTarget, +} from '../types' const FUNDING_SOURCE_LABEL: Record = { wallet: 'Wallet', @@ -62,21 +66,41 @@ function settlementReferences( interface BillingSettlementTableProps { items: BillingSettlementReconciliationItem[] - onSelectItem: (item: BillingSettlementReconciliationItem) => void + selectedTargets: ReadonlyMap + reviewPending: boolean + onSelectedTargetsChange: ( + targets: Map + ) => void + onReviewTargets: (targets: BillingSettlementReviewTarget[]) => void } export function BillingSettlementTable( props: BillingSettlementTableProps ): ReactElement { const { t, i18n } = useTranslation() + const { allSelected, someSelected, isSelected, toggleAll, toggleItem } = + useBillingSettlementSelection({ + items: props.items, + selectedTargets: props.selectedTargets, + onSelectedTargetsChange: props.onSelectedTargetsChange, + }) return (
- +
+ + toggleAll(checked === true)} + disabled={props.reviewPending} + aria-label={t('Select all billing reconciliation alerts')} + /> + {t('Operation')} - {t('Financial / alert status')} + {t('Financial status')} {t('User access')} {t('References')} {t('Funding source')} @@ -85,7 +109,6 @@ export function BillingSettlementTable( {t('Attempts / next retry')} {t('Last error')} - {t('Administrator review')} {t('Created')} {t('Actions')} @@ -97,6 +120,21 @@ export function BillingSettlementTable( const references = settlementReferences(item, t) return ( + + + toggleItem(item, checked === true) + } + disabled={props.reviewPending} + aria-label={t( + 'Select billing reconciliation alert {{id}}', + { + id: item.id, + } + )} + /> + {item.operation_key} @@ -111,17 +149,7 @@ export function BillingSettlementTable( > {item.status === 'manual' ? t('Manual') : t('Pending')} - 0 - ? 'secondary' - : 'destructive' - } - > - {item.reconciliation_reviewed_at > 0 - ? t('Reviewed') - : t('Open alert')} - + {t('Open alert')} @@ -184,39 +212,6 @@ export function BillingSettlementTable( {item.last_error || t('No error detail')} - - {item.reconciliation_reviewed_at > 0 ? ( -
- - {t('Administrator #{{id}}', { - id: item.reconciliation_reviewed_by, - })} - - - {formatTimestampRelative( - item.reconciliation_reviewed_at, - 'seconds', - i18n.language - )} - - - {item.reconciliation_review_note} - -
- ) : ( - - {t('Not reviewed')} - - )} -
props.onSelectItem(item)} + onClick={() => + props.onReviewTargets([ + { id: item.id, revision: item.revision }, + ]) + } + disabled={props.reviewPending} > - {item.reconciliation_reviewed_at > 0 && ( -
diff --git a/web/default/src/features/smart-ops/components/settlement-review-dialog.tsx b/web/default/src/features/smart-ops/components/settlement-review-dialog.tsx deleted file mode 100644 index 7a1207dd..00000000 --- a/web/default/src/features/smart-ops/components/settlement-review-dialog.tsx +++ /dev/null @@ -1,229 +0,0 @@ -/* -Copyright (C) 2023-2026 MAX-API-Next - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues -*/ -import { useState, type ReactElement } from 'react' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { Loader2, LockKeyhole, LockOpen } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' -import { handleServerError } from '@/lib/handle-server-error' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - Field, - FieldDescription, - FieldError, - FieldGroup, - FieldLabel, -} from '@/components/ui/field' -import { Textarea } from '@/components/ui/textarea' -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' -import { reviewBillingSettlement } from '../api' -import { formatLocalizedCount } from '../lib/format' -import { mutationErrorMessage } from '../lib/mutation-error' -import { - SMART_OPS_ACTIVE_ALERTS_QUERY_KEY, - SMART_OPS_BILLING_RECONCILIATION_QUERY_KEY, -} from '../lib/query-keys' -import type { BillingSettlementReconciliationItem } from '../types' - -interface SettlementReviewDialogProps { - item: BillingSettlementReconciliationItem - open: boolean - onOpenChange: (open: boolean) => void -} - -export function SettlementReviewDialog( - props: SettlementReviewDialogProps -): ReactElement { - const { t, i18n } = useTranslation() - const [decision, setDecision] = useState<'block' | 'allow'>( - props.item.record_blocks_user ? 'block' : 'allow' - ) - const [note, setNote] = useState(props.item.reconciliation_review_note || '') - const queryClient = useQueryClient() - - const trimmedNote = note.trim() - const noteLength = Array.from(trimmedNote).length - const noteInvalid = noteLength < 3 || noteLength > 1000 - - const reviewMutation = useMutation({ - mutationKey: ['smart-ops', 'billing-settlement-review', props.item.id], - mutationFn: async () => { - const response = await reviewBillingSettlement(props.item.id, { - block_user: decision === 'block', - note: trimmedNote, - }) - if (!response.success) { - throw new Error(response.message || t('Failed to save review.')) - } - }, - onSuccess: async () => { - toast.success(t('Billing reconciliation alert closed.')) - props.onOpenChange(false) - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: SMART_OPS_ACTIVE_ALERTS_QUERY_KEY, - }), - queryClient.invalidateQueries({ - queryKey: SMART_OPS_BILLING_RECONCILIATION_QUERY_KEY, - }), - ]) - }, - onError: (error) => { - handleServerError(error, { - fallback: mutationErrorMessage(error, t('Failed to save review.')), - }) - }, - }) - - const handleSubmit = () => { - if (noteLength < 3 || noteLength > 1000) return - reviewMutation.mutate() - } - - return ( - { - if (!reviewMutation.isPending) props.onOpenChange(open) - }} - > - - - - {props.item.reconciliation_reviewed_at - ? t('Edit reconciliation review') - : t('Review billing reconciliation')} - - - {t( - 'Closing this alert records an administrator review only. It does not mark the settlement as applied or change any balance.' - )} - - - - - - {t('User access while unresolved')} - { - const nextDecision = value[0] - if (nextDecision === 'block' || nextDecision === 'allow') { - setDecision(nextDecision) - } - }} - variant='outline' - spacing={2} - className='grid w-full grid-cols-1 sm:grid-cols-2' - > - - - - - - - {t( - 'This choice affects only new paid-request admission while the financial settlement remains pending or manual.' - )} - - - - - - {t('Review note')} - -