From 18dd451e23931e07cabb1a638c5e475d83312b92 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Mon, 31 Aug 2026 20:43:35 +0800 Subject: [PATCH 1/8] fix bugs --- model/billing_settlement.go | 9 ++- service/billing_session_test.go | 66 +++++++++++++++++++ .../billing_reconciliation_setting/config.go | 9 +-- .../config_test.go | 13 ++-- .../src/features/smart-ops/alerts.test.tsx | 21 +++--- .../billing-settlement-evidence.tsx | 4 +- .../src/features/smart-ops/lib/format.test.ts | 20 +++++- web/default/src/features/smart-ops/types.ts | 2 + web/default/src/i18n/locales/en.json | 4 +- web/default/src/i18n/locales/fr.json | 4 +- web/default/src/i18n/locales/ja.json | 4 +- web/default/src/i18n/locales/ru.json | 4 +- web/default/src/i18n/locales/vi.json | 4 +- web/default/src/i18n/locales/zh.json | 4 +- 14 files changed, 132 insertions(+), 36 deletions(-) diff --git a/model/billing_settlement.go b/model/billing_settlement.go index 93de9fb8..9f14f931 100644 --- a/model/billing_settlement.go +++ b/model/billing_settlement.go @@ -210,9 +210,12 @@ type BillingSettlementReconciliationItem struct { } type BillingSettlementReconciliationData struct { - TotalCount int64 `json:"total_count"` - PendingCount int64 `json:"pending_count"` - ManualCount int64 `json:"manual_count"` + TotalCount int64 `json:"total_count"` + // PendingCount and ManualCount classify unresolved financial settlement + // states. They are independent from the administrator-review counts below. + PendingCount int64 `json:"pending_count"` + ManualCount int64 `json:"manual_count"` + // OpenAlertCount and ReviewedCount classify administrator-review state. OpenAlertCount int64 `json:"open_alert_count"` ReviewedCount int64 `json:"reviewed_count"` BlockingRecordCount int64 `json:"blocking_record_count"` 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/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/features/smart-ops/alerts.test.tsx b/web/default/src/features/smart-ops/alerts.test.tsx index 2b242084..e6c0cfed 100644 --- a/web/default/src/features/smart-ops/alerts.test.tsx +++ b/web/default/src/features/smart-ops/alerts.test.tsx @@ -175,11 +175,11 @@ 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: 51, + pending_count: 1, + manual_count: 50, + open_alert_count: 24, + reviewed_count: 27, blocking_record_count: 1, blocked_user_count: 9, block_user_by_default: true, @@ -227,7 +227,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 +250,13 @@ 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: 27')) + assert.ok(text.includes('Pending settlements: 1')) + 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')) 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..17814e68 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 @@ -181,12 +181,12 @@ export function BillingSettlementEvidence( )} - {t('Pending: {{count}}', { + {t('Pending settlements: {{count}}', { count: formatCount(props.data.pending_count, i18n.language), })} - {t('Manual: {{count}}', { + {t('Manual settlements: {{count}}', { count: formatCount(props.data.manual_count, i18n.language), })} diff --git a/web/default/src/features/smart-ops/lib/format.test.ts b/web/default/src/features/smart-ops/lib/format.test.ts index d933e9d5..32b07058 100644 --- a/web/default/src/features/smart-ops/lib/format.test.ts +++ b/web/default/src/features/smart-ops/lib/format.test.ts @@ -152,8 +152,24 @@ describe('formatLocalizedCount', () => { assert.equal(fr.translation.Reviewed, 'Examiné') assert.doesNotMatch(ru.translation[closeAlertKey], /\bapplied\b/i) assert.equal( - ru.translation['Manual: {{count}}'], - 'Ручная обработка: {{count}}' + ru.translation['Manual settlements: {{count}}'], + 'Расчёты на ручной обработке: {{count}}' + ) + assert.equal( + ru.translation['Pending settlements: {{count}}'], + 'Расчёты в ожидании повтора: {{count}}' + ) + assert.equal( + zh.translation['Pending settlements: {{count}}'], + '待重试结算:{{count}}' + ) + assert.equal( + zh.translation['Manual settlements: {{count}}'], + '需人工处理的结算:{{count}}' + ) + assert.doesNotMatch( + zh.translation['Pending settlements: {{count}}'], + /^待处理/ ) assert.equal(ru.translation['Open alert'], 'Открытое оповещение') assert.equal( diff --git a/web/default/src/features/smart-ops/types.ts b/web/default/src/features/smart-ops/types.ts index 699d332c..0fbe9f05 100644 --- a/web/default/src/features/smart-ops/types.ts +++ b/web/default/src/features/smart-ops/types.ts @@ -69,8 +69,10 @@ export type BillingSettlementReconciliationItem = { export type BillingSettlementReconciliationData = { total_count: number + // Financial settlement states; these are not administrator-review counts. pending_count: number manual_count: number + // Administrator-review states for the same unresolved settlement records. open_alert_count: number reviewed_count: number blocking_record_count: number diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 4131b497..82a95f3f 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -22,10 +22,10 @@ "Funding source": "Funding source", "Manual": "Manual", "Manual review": "Manual review", - "Manual: {{count}}": "Manual: {{count}}", + "Manual settlements: {{count}}": "Manual settlements: {{count}}", "No error detail": "No error detail", "Outstanding funding": "Outstanding funding", - "Pending: {{count}}": "Pending: {{count}}", + "Pending settlements: {{count}}": "Pending settlements: {{count}}", "References": "References", "Showing the oldest {{count}} records; the summary covers all {{total}} unresolved records.": "Showing the oldest {{count}} records; the summary covers all {{total}} unresolved records.", "SmartOps reports sustained host pressure and billing reconciliation backlogs here for manual investigation.": "SmartOps reports sustained host pressure and billing reconciliation backlogs here for manual investigation.", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 0b7e2f4b..175702ef 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -22,10 +22,10 @@ "Funding source": "Source de financement", "Manual": "Manuel", "Manual review": "Examen manuel", - "Manual: {{count}}": "Manuel : {{count}}", + "Manual settlements: {{count}}": "Règlements manuels : {{count}}", "No error detail": "Aucun détail d’erreur", "Outstanding funding": "Financement en attente", - "Pending: {{count}}": "En attente : {{count}}", + "Pending settlements: {{count}}": "Règlements en attente : {{count}}", "References": "Références", "Showing the oldest {{count}} records; the summary covers all {{total}} unresolved records.": "Affichage des {{count}} enregistrements les plus anciens ; le résumé couvre les {{total}} enregistrements non résolus.", "SmartOps reports sustained host pressure and billing reconciliation backlogs here for manual investigation.": "SmartOps signale ici la pression persistante sur l’hôte et les retards de rapprochement de facturation pour examen manuel.", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 095ca5e4..57640863 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -22,10 +22,10 @@ "Funding source": "資金元", "Manual": "手動対応", "Manual review": "手動確認", - "Manual: {{count}}": "手動対応:{{count}}", + "Manual settlements: {{count}}": "手動処理の精算:{{count}}", "No error detail": "エラー詳細なし", "Outstanding funding": "未処理の資金差額", - "Pending: {{count}}": "保留中:{{count}}", + "Pending settlements: {{count}}": "再試行待ちの精算:{{count}}", "References": "関連情報", "Showing the oldest {{count}} records; the summary covers all {{total}} unresolved records.": "最も古い {{count}} 件を表示しています。概要には未解決の全 {{total}} 件が含まれます。", "SmartOps reports sustained host pressure and billing reconciliation backlogs here for manual investigation.": "SmartOps は、継続的なホスト負荷と請求照合の滞留を手動調査用にここへ報告します。", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 3faf9c58..8ff93967 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -22,10 +22,10 @@ "Funding source": "Источник средств", "Manual": "Ручная обработка", "Manual review": "Ручная проверка", - "Manual: {{count}}": "Ручная обработка: {{count}}", + "Manual settlements: {{count}}": "Расчёты на ручной обработке: {{count}}", "No error detail": "Нет сведений об ошибке", "Outstanding funding": "Неприменённая сумма", - "Pending: {{count}}": "В ожидании: {{count}}", + "Pending settlements: {{count}}": "Расчёты в ожидании повтора: {{count}}", "References": "Связанные объекты", "Showing the oldest {{count}} records; the summary covers all {{total}} unresolved records.": "Показаны {{count}} самых старых записей; сводка охватывает все {{total}} неразрешённых записей.", "SmartOps reports sustained host pressure and billing reconciliation backlogs here for manual investigation.": "SmartOps сообщает здесь о длительной нагрузке на узел и очередях сверки биллинга для ручной диагностики.", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 05138c12..3d8fad4d 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -22,10 +22,10 @@ "Funding source": "Nguồn tiền", "Manual": "Xử lý thủ công", "Manual review": "Kiểm tra thủ công", - "Manual: {{count}}": "Thủ công: {{count}}", + "Manual settlements: {{count}}": "Quyết toán xử lý thủ công: {{count}}", "No error detail": "Không có chi tiết lỗi", "Outstanding funding": "Chênh lệch tiền chưa xử lý", - "Pending: {{count}}": "Đang chờ: {{count}}", + "Pending settlements: {{count}}": "Quyết toán chờ thử lại: {{count}}", "References": "Đối tượng liên quan", "Showing the oldest {{count}} records; the summary covers all {{total}} unresolved records.": "Đang hiển thị {{count}} bản ghi cũ nhất; phần tóm tắt bao gồm toàn bộ {{total}} bản ghi chưa xử lý.", "SmartOps reports sustained host pressure and billing reconciliation backlogs here for manual investigation.": "SmartOps báo cáo tại đây tình trạng áp lực máy chủ kéo dài và tồn đọng đối soát tính phí để quản trị viên kiểm tra thủ công.", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 9d4219a9..07ac4c2e 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -22,10 +22,10 @@ "Funding source": "资金来源", "Manual": "人工处理", "Manual review": "需要人工核对", - "Manual: {{count}}": "人工处理:{{count}}", + "Manual settlements: {{count}}": "需人工处理的结算:{{count}}", "No error detail": "无错误详情", "Outstanding funding": "未完成资金差额", - "Pending: {{count}}": "待处理:{{count}}", + "Pending settlements: {{count}}": "待重试结算:{{count}}", "References": "关联对象", "Showing the oldest {{count}} records; the summary covers all {{total}} unresolved records.": "当前展示最早的 {{count}} 条记录;摘要覆盖全部 {{total}} 条未解决记录。", "SmartOps reports sustained host pressure and billing reconciliation backlogs here for manual investigation.": "SmartOps 会在此报告持续的主机资源压力和计费对账积压,供管理员人工排查。", From 83774720f2b8f9ebd2a301d52b2b2b4eda7abd29 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Mon, 31 Aug 2026 21:16:20 +0800 Subject: [PATCH 2/8] fix: keep pending settlement labels neutral --- .../src/features/smart-ops/lib/format.test.ts | 26 ++++++++++++++++--- web/default/src/i18n/locales/ja.json | 2 +- web/default/src/i18n/locales/ru.json | 2 +- web/default/src/i18n/locales/vi.json | 2 +- web/default/src/i18n/locales/zh.json | 2 +- 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/web/default/src/features/smart-ops/lib/format.test.ts b/web/default/src/features/smart-ops/lib/format.test.ts index 32b07058..4644cefa 100644 --- a/web/default/src/features/smart-ops/lib/format.test.ts +++ b/web/default/src/features/smart-ops/lib/format.test.ts @@ -157,11 +157,19 @@ describe('formatLocalizedCount', () => { ) assert.equal( ru.translation['Pending settlements: {{count}}'], - 'Расчёты в ожидании повтора: {{count}}' + 'Расчёты в ожидании: {{count}}' + ) + assert.equal( + ja.translation['Pending settlements: {{count}}'], + '保留中の精算:{{count}}' + ) + assert.equal( + vi.translation['Pending settlements: {{count}}'], + 'Quyết toán đang chờ: {{count}}' ) assert.equal( zh.translation['Pending settlements: {{count}}'], - '待重试结算:{{count}}' + '待结算:{{count}}' ) assert.equal( zh.translation['Manual settlements: {{count}}'], @@ -169,7 +177,19 @@ describe('formatLocalizedCount', () => { ) assert.doesNotMatch( zh.translation['Pending settlements: {{count}}'], - /^待处理/ + /重试|处理/ + ) + assert.doesNotMatch( + ja.translation['Pending settlements: {{count}}'], + /再試行/ + ) + assert.doesNotMatch( + ru.translation['Pending settlements: {{count}}'], + /повтор/i + ) + assert.doesNotMatch( + vi.translation['Pending settlements: {{count}}'], + /thử lại/i ) assert.equal(ru.translation['Open alert'], 'Открытое оповещение') assert.equal( diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 57640863..4282729a 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -25,7 +25,7 @@ "Manual settlements: {{count}}": "手動処理の精算:{{count}}", "No error detail": "エラー詳細なし", "Outstanding funding": "未処理の資金差額", - "Pending settlements: {{count}}": "再試行待ちの精算:{{count}}", + "Pending settlements: {{count}}": "保留中の精算:{{count}}", "References": "関連情報", "Showing the oldest {{count}} records; the summary covers all {{total}} unresolved records.": "最も古い {{count}} 件を表示しています。概要には未解決の全 {{total}} 件が含まれます。", "SmartOps reports sustained host pressure and billing reconciliation backlogs here for manual investigation.": "SmartOps は、継続的なホスト負荷と請求照合の滞留を手動調査用にここへ報告します。", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 8ff93967..e442d2b7 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -25,7 +25,7 @@ "Manual settlements: {{count}}": "Расчёты на ручной обработке: {{count}}", "No error detail": "Нет сведений об ошибке", "Outstanding funding": "Неприменённая сумма", - "Pending settlements: {{count}}": "Расчёты в ожидании повтора: {{count}}", + "Pending settlements: {{count}}": "Расчёты в ожидании: {{count}}", "References": "Связанные объекты", "Showing the oldest {{count}} records; the summary covers all {{total}} unresolved records.": "Показаны {{count}} самых старых записей; сводка охватывает все {{total}} неразрешённых записей.", "SmartOps reports sustained host pressure and billing reconciliation backlogs here for manual investigation.": "SmartOps сообщает здесь о длительной нагрузке на узел и очередях сверки биллинга для ручной диагностики.", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 3d8fad4d..f922acc5 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -25,7 +25,7 @@ "Manual settlements: {{count}}": "Quyết toán xử lý thủ công: {{count}}", "No error detail": "Không có chi tiết lỗi", "Outstanding funding": "Chênh lệch tiền chưa xử lý", - "Pending settlements: {{count}}": "Quyết toán chờ thử lại: {{count}}", + "Pending settlements: {{count}}": "Quyết toán đang chờ: {{count}}", "References": "Đối tượng liên quan", "Showing the oldest {{count}} records; the summary covers all {{total}} unresolved records.": "Đang hiển thị {{count}} bản ghi cũ nhất; phần tóm tắt bao gồm toàn bộ {{total}} bản ghi chưa xử lý.", "SmartOps reports sustained host pressure and billing reconciliation backlogs here for manual investigation.": "SmartOps báo cáo tại đây tình trạng áp lực máy chủ kéo dài và tồn đọng đối soát tính phí để quản trị viên kiểm tra thủ công.", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 07ac4c2e..313972c3 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -25,7 +25,7 @@ "Manual settlements: {{count}}": "需人工处理的结算:{{count}}", "No error detail": "无错误详情", "Outstanding funding": "未完成资金差额", - "Pending settlements: {{count}}": "待重试结算:{{count}}", + "Pending settlements: {{count}}": "待结算:{{count}}", "References": "关联对象", "Showing the oldest {{count}} records; the summary covers all {{total}} unresolved records.": "当前展示最早的 {{count}} 条记录;摘要覆盖全部 {{total}} 条未解决记录。", "SmartOps reports sustained host pressure and billing reconciliation backlogs here for manual investigation.": "SmartOps 会在此报告持续的主机资源压力和计费对账积压,供管理员人工排查。", From 5416915aa75a1ff61057de118a5c100d447c0df5 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Mon, 31 Aug 2026 22:13:51 +0800 Subject: [PATCH 3/8] fix: clarify Vietnamese settlement states --- web/default/src/features/smart-ops/lib/format.test.ts | 6 +++++- web/default/src/i18n/locales/vi.json | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/web/default/src/features/smart-ops/lib/format.test.ts b/web/default/src/features/smart-ops/lib/format.test.ts index 4644cefa..661f2a98 100644 --- a/web/default/src/features/smart-ops/lib/format.test.ts +++ b/web/default/src/features/smart-ops/lib/format.test.ts @@ -165,7 +165,11 @@ describe('formatLocalizedCount', () => { ) assert.equal( vi.translation['Pending settlements: {{count}}'], - 'Quyết toán đang chờ: {{count}}' + 'Quyết toán đang chờ xử lý: {{count}}' + ) + assert.equal( + vi.translation['Manual settlements: {{count}}'], + 'Quyết toán thủ công: {{count}}' ) assert.equal( zh.translation['Pending settlements: {{count}}'], diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index f922acc5..d6c46782 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -22,10 +22,10 @@ "Funding source": "Nguồn tiền", "Manual": "Xử lý thủ công", "Manual review": "Kiểm tra thủ công", - "Manual settlements: {{count}}": "Quyết toán xử lý thủ công: {{count}}", + "Manual settlements: {{count}}": "Quyết toán thủ công: {{count}}", "No error detail": "Không có chi tiết lỗi", "Outstanding funding": "Chênh lệch tiền chưa xử lý", - "Pending settlements: {{count}}": "Quyết toán đang chờ: {{count}}", + "Pending settlements: {{count}}": "Quyết toán đang chờ xử lý: {{count}}", "References": "Đối tượng liên quan", "Showing the oldest {{count}} records; the summary covers all {{total}} unresolved records.": "Đang hiển thị {{count}} bản ghi cũ nhất; phần tóm tắt bao gồm toàn bộ {{total}} bản ghi chưa xử lý.", "SmartOps reports sustained host pressure and billing reconciliation backlogs here for manual investigation.": "SmartOps báo cáo tại đây tình trạng áp lực máy chủ kéo dài và tồn đọng đối soát tính phí để quản trị viên kiểm tra thủ công.", From b71b5d206f6a4ec298b23502986e36a20e4b00a4 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Mon, 31 Aug 2026 23:44:04 +0800 Subject: [PATCH 4/8] feat: streamline billing reconciliation review --- controller/audit.go | 1 + controller/smart_ops_alert.go | 53 ++++ controller/smart_ops_alert_test.go | 100 ++++++++ model/billing_settlement.go | 114 ++++++--- model/billing_settlement_regression_test.go | 171 ++++++++++++- router/api-router.go | 1 + service/smart_ops_alert.go | 28 ++- service/smart_ops_alert_test.go | 15 ++ .../src/features/smart-ops/alerts.test.tsx | 139 +++++------ web/default/src/features/smart-ops/api.ts | 5 +- .../billing-settlement-evidence.tsx | 132 +++++++--- .../components/billing-settlement-table.tsx | 110 +++++---- .../components/settlement-review-dialog.tsx | 229 ------------------ .../src/features/smart-ops/lib/format.test.ts | 110 +++------ .../lib/reconciliation-validation.test.ts | 2 +- .../lib/reconciliation-validation.ts | 2 +- web/default/src/features/smart-ops/types.ts | 12 +- web/default/src/i18n/locales/en.json | 14 ++ web/default/src/i18n/locales/fr.json | 14 ++ web/default/src/i18n/locales/ja.json | 14 ++ web/default/src/i18n/locales/ru.json | 14 ++ web/default/src/i18n/locales/vi.json | 14 ++ web/default/src/i18n/locales/zh.json | 14 ++ 23 files changed, 780 insertions(+), 528 deletions(-) delete mode 100644 web/default/src/features/smart-ops/components/settlement-review-dialog.tsx 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 9f14f931..d0964f99 100644 --- a/model/billing_settlement.go +++ b/model/billing_settlement.go @@ -185,6 +185,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"` @@ -211,12 +212,17 @@ type BillingSettlementReconciliationItem struct { type BillingSettlementReconciliationData struct { TotalCount int64 `json:"total_count"` - // PendingCount and ManualCount classify unresolved financial settlement - // states. They are independent from the administrator-review counts below. + // 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 and ReviewedCount classify administrator-review state. - OpenAlertCount int64 `json:"open_alert_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"` @@ -236,11 +242,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) } @@ -340,9 +346,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") @@ -351,8 +358,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") @@ -377,28 +386,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 @@ -410,14 +416,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). @@ -466,14 +471,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). @@ -524,6 +521,63 @@ 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{}{} + } + + reviewed := make([]BillingSettlement, 0, len(targets)) + err := DB.Transaction(func(tx *gorm.DB) error { + reviewedAt := time.Now() + for _, target := range targets { + 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 + } + + var record BillingSettlement + if err := tx.First(&record, target.ID).Error; err != nil { + return err + } + reviewed = append(reviewed, record) + } + 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..2209b285 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,128 @@ 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) + + reviewed, err := ReviewBillingSettlements([]BillingSettlementReviewTarget{ + {ID: records[0].ID, Revision: records[0].Revision}, + {ID: records[1].ID, Revision: records[1].Revision}, + }, 7002) + + require.NoError(t, err) + require.Len(t, reviewed, 2) + for index := range reviewed { + 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 TestBillingSettlementBlockingScopeDoesNotLeakAcrossUsers(t *testing.T) { setupUserUpdateTestState(t) setBillingReconciliationBlockDefaultForTest(t, true) @@ -448,6 +567,7 @@ func TestReviewBillingSettlementRejectsStaleFailureEvidence(t *testing.T) { func TestReviewBillingSettlementAllowsEditingCurrentReview(t *testing.T) { setupUserUpdateTestState(t) + setBillingReconciliationBlockDefaultForTest(t, false) now := time.Now().Unix() allow := false @@ -468,6 +588,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/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/web/default/src/features/smart-ops/alerts.test.tsx b/web/default/src/features/smart-ops/alerts.test.tsx index e6c0cfed..1877c847 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: 51, + total_count: 24, pending_count: 1, - manual_count: 50, + manual_count: 23, open_alert_count: 24, - reviewed_count: 27, 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', @@ -253,9 +252,10 @@ describe('SmartOps active alerts', () => { assert.ok(text.includes('24 records')) assert.ok(!text.includes('4,800.0%')) assert.ok(text.includes('Open alerts: 24')) - assert.ok(text.includes('Reviewed records: 27')) - assert.ok(text.includes('Pending settlements: 1')) - assert.ok(text.includes('Manual settlements: 50')) + 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')) @@ -266,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() @@ -387,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, @@ -408,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, @@ -434,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, }, ], }, @@ -468,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( @@ -480,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)', }) }) @@ -501,36 +501,29 @@ 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 selectAll = within(view.container).getByRole('checkbox', { + name: 'Select all billing reconciliation alerts', }) - 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(selectAll) + closeSelectedButton = within(view.container).getByRole('button', { + name: 'Review and close selected (2)', }) - await waitFor(() => - assert.equal(closeAlertButton.hasAttribute('disabled'), false) - ) + 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) 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 17814e68..e195ea06 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 @@ -18,7 +18,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { 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 { @@ -46,7 +49,6 @@ import type { BillingSettlementReconciliationItem, } from '../types' import { BillingSettlementTable } from './billing-settlement-table' -import { SettlementReviewDialog } from './settlement-review-dialog' interface BillingSettlementEvidenceProps { canUpdateBlockingPolicy: boolean @@ -60,12 +62,11 @@ export function BillingSettlementEvidence( props: BillingSettlementEvidenceProps ): ReactElement { const { t, i18n } = useTranslation() - const [selectedItem, setSelectedItem] = - useState(null) + const [selectedIDs, setSelectedIDs] = useState>(() => new Set()) 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 +107,51 @@ export function BillingSettlementEvidence( policyMutation.mutate(checked) } + const reviewMutation = useMutation({ + mutationKey: ['smart-ops', 'billing-settlement-reviews'], + mutationFn: async (items: BillingSettlementReconciliationItem[]) => { + const response = await reviewBillingSettlements({ + items: items.map((item) => ({ id: item.id, revision: item.revision })), + }) + if (!response.success) { + throw new Error( + response.message || t('Failed to close reconciliation alerts.') + ) + } + return items.length + }, + onSuccess: async (count) => { + setSelectedIDs(new Set()) + 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 reviewItems = (items: BillingSettlementReconciliationItem[]) => { + if (items.length > 0 && !reviewMutation.isPending) { + reviewMutation.mutate(items) + } + } + if (props.loading) { return (
@@ -142,6 +188,10 @@ export function BillingSettlementEvidence( return <> } + const selectedItems = props.data.items.filter((item) => + selectedIDs.has(item.id) + ) + return (
- {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 +222,12 @@ export function BillingSettlementEvidence( )} - {formatLocalizedCount( - props.data.reviewed_count, - i18n.language, - t, - 'Reviewed record: {{count}}', - 'Reviewed records: {{count}}' - )} - - - {t('Pending settlements: {{count}}', { + {t('Open pending settlements: {{count}}', { count: formatCount(props.data.pending_count, i18n.language), })} - {t('Manual settlements: {{count}}', { + {t('Open manual settlements: {{count}}', { count: formatCount(props.data.manual_count, i18n.language), })} @@ -230,25 +271,54 @@ export function BillingSettlementEvidence( {props.data.items.length === 0 ? ( ) : ( - +
+
+ + {t('Selected alerts: {{count}}', { + count: formatCount(selectedItems.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 +332,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..63ed53af 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, @@ -62,21 +62,52 @@ function settlementReferences( interface BillingSettlementTableProps { items: BillingSettlementReconciliationItem[] - onSelectItem: (item: BillingSettlementReconciliationItem) => void + selectedIDs: ReadonlySet + reviewPending: boolean + onSelectedIDsChange: (ids: Set) => void + onReviewItems: (items: BillingSettlementReconciliationItem[]) => void } export function BillingSettlementTable( props: BillingSettlementTableProps ): ReactElement { const { t, i18n } = useTranslation() + const allSelected = + props.items.length > 0 && + props.items.every((item) => props.selectedIDs.has(item.id)) + const someSelected = props.items.some((item) => + props.selectedIDs.has(item.id) + ) + + const toggleAll = (checked: boolean) => { + props.onSelectedIDsChange( + checked ? new Set(props.items.map((item) => item.id)) : new Set() + ) + } + + const toggleItem = (id: number, checked: boolean) => { + const next = new Set(props.selectedIDs) + if (checked) next.add(id) + else next.delete(id) + props.onSelectedIDsChange(next) + } 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 +116,6 @@ export function BillingSettlementTable( {t('Attempts / next retry')} {t('Last error')} - {t('Administrator review')} {t('Created')} {t('Actions')} @@ -97,6 +127,21 @@ export function BillingSettlementTable( const references = settlementReferences(item, t) return ( + + + toggleItem(item.id, checked === true) + } + disabled={props.reviewPending} + aria-label={t( + 'Select billing reconciliation alert {{id}}', + { + id: item.id, + } + )} + /> + {item.operation_key} @@ -111,17 +156,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 +219,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.onReviewItems([item])} + 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')} - -