Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions controller/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})",
Expand Down
53 changes: 53 additions & 0 deletions controller/smart_ops_alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
100 changes: 100 additions & 0 deletions controller/smart_ops_alert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}
124 changes: 94 additions & 30 deletions model/billing_settlement.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"sort"
"sync"
"time"

Expand Down Expand Up @@ -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"`
Expand All @@ -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"`
Expand All @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if blockUserByDefault {
return scope.Where("(user_blocking_override IS NULL OR user_blocking_override = ?)", true)
}
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 &&
Expand Down
Loading
Loading