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('No unresolved reconciliation records.')}
+ {t('No open reconciliation alerts.')}
{t(
- 'There are no pending or manual positive final settlements requiring operator review.'
+ 'There are no pending or manual positive final settlements waiting for administrator review.'
)}
) : (
-
+
+
+
+ {t('Selected alerts: {{count}}', {
+ count: formatCount(selectedItems.length, i18n.language),
+ })}
+
+ reviewItems(selectedItems)}
+ disabled={selectedItems.length === 0 || reviewMutation.isPending}
+ >
+ {reviewMutation.isPending && (
+
+ )}
+ {t('Review and close selected ({{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 && (
-
- )}
- {item.reconciliation_reviewed_at > 0
- ? t('Edit review')
- : t('Review and close')}
+ {t('Review and close')}
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('Continue blocking user')}
-
-
-
- {t('Allow user to continue')}
-
-
-
- {t(
- 'This choice affects only new paid-request admission while the financial settlement remains pending or manual.'
- )}
-
-
-
-
-
- {t('Review note')}
-
-
-
-
-
- props.onOpenChange(false)}
- disabled={reviewMutation.isPending}
- >
- {t('Cancel')}
-
- 1000
- }
- >
- {reviewMutation.isPending && (
-
- )}
- {props.item.reconciliation_reviewed_at
- ? t('Save review')
- : t('Close alert')}
-
-
-
-
- )
-}
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 661f2a98..b87279f7 100644
--- a/web/default/src/features/smart-ops/lib/format.test.ts
+++ b/web/default/src/features/smart-ops/lib/format.test.ts
@@ -16,7 +16,6 @@ along with this program. If not, see .
For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
-import en from '@/i18n/locales/en.json'
import fr from '@/i18n/locales/fr.json'
import ja from '@/i18n/locales/ja.json'
import ru from '@/i18n/locales/ru.json'
@@ -116,121 +115,70 @@ describe('formatLocalizedCount', () => {
)
})
- test('keeps reconciliation terminology localized and consistent', (): void => {
+ test('keeps active reconciliation terminology localized and consistent', (): void => {
const reviewSummaryKey =
- 'Review and close operational alerts without changing the underlying pending or manual financial settlement.'
- const emptyStateKey = 'No unresolved reconciliation records.'
+ 'Select one or more alerts and close them with one click. The underlying financial settlement record remains available for safe retry and audit.'
+ const selectionKey = 'Review and close selected ({{count}})'
const blockingPolicyKey = 'Block affected users by default'
- const userAccessHeadingKey = 'User access'
- const userAccessKey = 'User access while unresolved'
- const rootPolicyKey =
- 'Only root administrators can change the default blocking policy.'
- const closeAlertKey =
- 'Closing this alert records an administrator review only. It does not mark the settlement as applied or change any balance.'
- const positiveSettlementKey =
- 'When enabled, new paid requests remain blocked while any unresolved positive final settlement record still blocks the user. Allowing one reviewed record does not override other blocking records.'
- const oldPositiveSettlementKey =
- 'When enabled, unresolved positive final settlements block new paid requests unless a reviewed record explicitly allows the user to continue.'
- const paidAdmissionKey =
- 'This choice affects only new paid-request admission while the financial settlement remains pending or manual.'
assert.doesNotMatch(ru.translation[reviewSummaryKey], /pending|manual/i)
- assert.doesNotMatch(
- ja.translation[reviewSummaryKey],
- /決済|pending|manual/i
- )
- assert.equal(fr.translation['Token #{{id}}'], 'Jeton n° {{id}}')
- assert.equal(
- fr.translation['Administrator review'],
- 'Examen par l’administrateur'
- )
+ assert.doesNotMatch(ja.translation[reviewSummaryKey], /pending|manual/i)
assert.equal(
- fr.translation['Outstanding funding'],
- 'Financement en attente'
+ ru.translation['Open manual settlements: {{count}}'],
+ 'Открытые расчёты на ручной обработке: {{count}}'
)
- assert.doesNotMatch(fr.translation['Administrator review'], /vérification/i)
- assert.equal(fr.translation.Reviewed, 'Examiné')
- assert.doesNotMatch(ru.translation[closeAlertKey], /\bapplied\b/i)
assert.equal(
- ru.translation['Manual settlements: {{count}}'],
- 'Расчёты на ручной обработке: {{count}}'
+ ru.translation['Open pending settlements: {{count}}'],
+ 'Открытые расчёты в ожидании: {{count}}'
)
assert.equal(
- ru.translation['Pending settlements: {{count}}'],
- 'Расчёты в ожидании: {{count}}'
+ ja.translation['Open pending settlements: {{count}}'],
+ '未対応の保留中精算:{{count}}件'
)
assert.equal(
- ja.translation['Pending settlements: {{count}}'],
- '保留中の精算:{{count}}'
+ vi.translation['Open pending settlements: {{count}}'],
+ 'Quyết toán đang chờ có cảnh báo mở: {{count}}'
)
assert.equal(
- vi.translation['Pending settlements: {{count}}'],
- 'Quyết toán đang chờ xử lý: {{count}}'
+ vi.translation['Open manual settlements: {{count}}'],
+ 'Quyết toán thủ công có cảnh báo mở: {{count}}'
)
assert.equal(
- vi.translation['Manual settlements: {{count}}'],
- 'Quyết toán thủ công: {{count}}'
+ zh.translation['Open pending settlements: {{count}}'],
+ '未关闭的待结算:{{count}}'
)
assert.equal(
- zh.translation['Pending settlements: {{count}}'],
- '待结算:{{count}}'
- )
- assert.equal(
- zh.translation['Manual settlements: {{count}}'],
- '需人工处理的结算:{{count}}'
+ zh.translation['Open manual settlements: {{count}}'],
+ '未关闭的需人工处理结算:{{count}}'
)
assert.doesNotMatch(
- zh.translation['Pending settlements: {{count}}'],
+ zh.translation['Open pending settlements: {{count}}'],
/重试|处理/
)
assert.doesNotMatch(
- ja.translation['Pending settlements: {{count}}'],
+ ja.translation['Open pending settlements: {{count}}'],
/再試行/
)
assert.doesNotMatch(
- ru.translation['Pending settlements: {{count}}'],
+ ru.translation['Open pending settlements: {{count}}'],
/повтор/i
)
assert.doesNotMatch(
- vi.translation['Pending settlements: {{count}}'],
+ vi.translation['Open pending settlements: {{count}}'],
/thử lại/i
)
- assert.equal(ru.translation['Open alert'], 'Открытое оповещение')
- assert.equal(
- ru.translation['Open alert: {{count}}'],
- 'Открытое оповещение: {{count}}'
- )
- assert.equal(
- vi.translation['Administrator controls'],
- 'Kiểm soát dành cho quản trị viên'
- )
assert.equal(
- vi.translation['Billing reconciliation controls'],
- 'Kiểm soát đối soát tính phí'
+ vi.translation['Open billing reconciliation alerts'],
+ 'Cảnh báo đối soát tính phí đang mở'
)
assert.equal(
- vi.translation['Attempts / next retry'],
- 'Số lần thử / lần thử lại tiếp theo'
- )
- assert.equal(
- vi.translation[paidAdmissionKey],
- 'Lựa chọn này chỉ ảnh hưởng đến việc tiếp nhận yêu cầu có tính phí mới trong khi quyết toán tài chính vẫn ở trạng thái đang chờ hoặc xử lý thủ công.'
+ zh.translation['No open reconciliation alerts.'],
+ '没有未关闭的对账告警。'
)
+ assert.equal(zh.translation[blockingPolicyKey], '默认阻止受影响用户')
assert.equal(
- vi.translation[positiveSettlementKey],
- 'Khi bật, các yêu cầu có tính phí mới vẫn bị chặn chừng nào còn bất kỳ bản ghi quyết toán cuối có số tiền dương chưa xử lý nào chặn người dùng. Việc cho phép một bản ghi đã kiểm tra không vô hiệu hóa các bản ghi chặn khác.'
+ zh.translation[selectionKey],
+ '核对并关闭所选告警({{count}})'
)
- assert.equal(en.translation[positiveSettlementKey], positiveSettlementKey)
- for (const locale of [en, fr, ja, ru, vi, zh]) {
- assert.equal(
- Object.hasOwn(locale.translation, oldPositiveSettlementKey),
- false
- )
- }
- assert.match(vi.translation[rootPolicyKey], /quản trị viên Root/)
- assert.equal(zh.translation[emptyStateKey], '没有未解决的对账记录。')
- assert.equal(zh.translation[blockingPolicyKey], '默认阻止受影响用户')
- assert.equal(zh.translation[userAccessHeadingKey], '用户访问状态')
- assert.equal(zh.translation[userAccessKey], '未解决期间的用户访问权限')
})
})
diff --git a/web/default/src/features/smart-ops/lib/reconciliation-validation.test.ts b/web/default/src/features/smart-ops/lib/reconciliation-validation.test.ts
index ca697e0b..86e2223e 100644
--- a/web/default/src/features/smart-ops/lib/reconciliation-validation.test.ts
+++ b/web/default/src/features/smart-ops/lib/reconciliation-validation.test.ts
@@ -27,7 +27,6 @@ function validData(): BillingSettlementReconciliationData {
pending_count: 1,
manual_count: 0,
open_alert_count: 1,
- reviewed_count: 0,
blocking_record_count: 1,
blocked_user_count: 1,
block_user_by_default: true,
@@ -37,6 +36,7 @@ function validData(): BillingSettlementReconciliationData {
items: [
{
id: 3,
+ revision: 1,
operation_key: 'request:validation:finalize',
status: 'pending',
source: 'wallet',
diff --git a/web/default/src/features/smart-ops/lib/reconciliation-validation.ts b/web/default/src/features/smart-ops/lib/reconciliation-validation.ts
index fcaf9e7e..5f08749b 100644
--- a/web/default/src/features/smart-ops/lib/reconciliation-validation.ts
+++ b/web/default/src/features/smart-ops/lib/reconciliation-validation.ts
@@ -25,6 +25,7 @@ import type {
const billingSettlementReconciliationItemSchema: z.ZodType =
z.object({
id: z.number(),
+ revision: z.number(),
operation_key: z.string(),
status: z.enum(['pending', 'manual']),
source: z.enum(['wallet', 'subscription']),
@@ -55,7 +56,6 @@ const billingSettlementReconciliationDataSchema: z.ZodType
}
export type BillingSettlementReconciliationResponse = {
diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json
index 82a95f3f..c48ff705 100644
--- a/web/default/src/i18n/locales/en.json
+++ b/web/default/src/i18n/locales/en.json
@@ -5526,6 +5526,7 @@
"Administrator review": "Administrator review",
"Allow user to continue": "Allow user to continue",
"Billing reconciliation alert closed.": "Billing reconciliation alert closed.",
+ "Billing reconciliation alerts closed: {{count}}": "Billing reconciliation alerts closed: {{count}}",
"Billing reconciliation controls": "Billing reconciliation controls",
"Block affected users by default": "Block affected users by default",
"Close alert": "Close alert",
@@ -5534,10 +5535,13 @@
"Default user-blocking policy updated.": "Default user-blocking policy updated.",
"Edit reconciliation review": "Edit reconciliation review",
"Edit review": "Edit review",
+ "Failed to close reconciliation alerts.": "Failed to close reconciliation alerts.",
"Failed to save review.": "Failed to save review.",
"Failed to update blocking policy.": "Failed to update blocking policy.",
"Financial / alert status": "Financial / alert status",
+ "Financial status": "Financial status",
"Last error": "Last error",
+ "No open reconciliation alerts.": "No open reconciliation alerts.",
"No unresolved reconciliation records.": "No unresolved reconciliation records.",
"Node": "Node",
"Not reviewed": "Not reviewed",
@@ -5545,10 +5549,14 @@
"Open alert": "Open alert",
"Open alert: {{count}}": "Open alert: {{count}}",
"Open alerts: {{count}}": "Open alerts: {{count}}",
+ "Open billing reconciliation alerts": "Open billing reconciliation alerts",
+ "Open manual settlements: {{count}}": "Open manual settlements: {{count}}",
+ "Open pending settlements: {{count}}": "Open pending settlements: {{count}}",
"Record policy: allow": "Record policy: allow",
"Record policy: block": "Record policy: block",
"Record the evidence checked, the conclusion, and any follow-up action.": "Record the evidence checked, the conclusion, and any follow-up action.",
"Review and close": "Review and close",
+ "Review and close selected ({{count}})": "Review and close selected ({{count}})",
"Review and close operational alerts without changing the underlying pending or manual financial settlement.": "Review and close operational alerts without changing the underlying pending or manual financial settlement.",
"Review billing reconciliation": "Review billing reconciliation",
"Review note": "Review note",
@@ -5557,7 +5565,13 @@
"Reviewed record: {{count}}": "Reviewed record: {{count}}",
"Reviewed records: {{count}}": "Reviewed records: {{count}}",
"Save review": "Save review",
+ "Select all billing reconciliation alerts": "Select all billing reconciliation alerts",
+ "Select billing reconciliation alert {{id}}": "Select billing reconciliation alert {{id}}",
+ "Select one or more alerts and close them with one click. The underlying financial settlement record remains available for safe retry and audit.": "Select one or more alerts and close them with one click. The underlying financial settlement record remains available for safe retry and audit.",
+ "Selected alerts: {{count}}": "Selected alerts: {{count}}",
+ "Showing the oldest {{count}} alerts; the summary covers all {{total}} open alerts.": "Showing the oldest {{count}} alerts; the summary covers all {{total}} open alerts.",
"There are no pending or manual positive final settlements requiring operator review.": "There are no pending or manual positive final settlements requiring operator review.",
+ "There are no pending or manual positive final settlements waiting for administrator review.": "There are no pending or manual positive final settlements waiting for administrator review.",
"This choice affects only new paid-request admission while the financial settlement remains pending or manual.": "This choice affects only new paid-request admission while the financial settlement remains pending or manual.",
"This process is not currently reporting sustained host pressure or an open billing reconciliation alert.": "This process is not currently reporting sustained host pressure or an open billing reconciliation alert.",
"User access": "User access",
diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json
index 175702ef..2567ed69 100644
--- a/web/default/src/i18n/locales/fr.json
+++ b/web/default/src/i18n/locales/fr.json
@@ -5526,6 +5526,7 @@
"Administrator review": "Examen par l’administrateur",
"Allow user to continue": "Autoriser l’utilisateur à continuer",
"Billing reconciliation alert closed.": "Alerte de rapprochement de facturation fermée.",
+ "Billing reconciliation alerts closed: {{count}}": "Alertes de rapprochement de facturation fermées : {{count}}",
"Billing reconciliation controls": "Contrôles du rapprochement de facturation",
"Block affected users by default": "Bloquer les utilisateurs concernés par défaut",
"Close alert": "Fermer l’alerte",
@@ -5534,10 +5535,13 @@
"Default user-blocking policy updated.": "La politique de blocage par défaut a été mise à jour.",
"Edit reconciliation review": "Modifier l’examen du rapprochement",
"Edit review": "Modifier l’examen",
+ "Failed to close reconciliation alerts.": "Échec de la fermeture des alertes de rapprochement.",
"Failed to save review.": "Échec de l’enregistrement de l’examen.",
"Failed to update blocking policy.": "Échec de la mise à jour de la politique de blocage.",
"Financial / alert status": "État financier / de l’alerte",
+ "Financial status": "État financier",
"Last error": "Dernière erreur",
+ "No open reconciliation alerts.": "Aucune alerte de rapprochement ouverte.",
"No unresolved reconciliation records.": "Aucun enregistrement de rapprochement non résolu.",
"Node": "Nœud",
"Not reviewed": "Non examiné",
@@ -5545,10 +5549,14 @@
"Open alert": "Alerte ouverte",
"Open alert: {{count}}": "Alerte ouverte : {{count}}",
"Open alerts: {{count}}": "Alertes ouvertes : {{count}}",
+ "Open billing reconciliation alerts": "Alertes ouvertes de rapprochement de facturation",
+ "Open manual settlements: {{count}}": "Règlements manuels avec alerte ouverte : {{count}}",
+ "Open pending settlements: {{count}}": "Règlements en attente avec alerte ouverte : {{count}}",
"Record policy: allow": "Politique de l’enregistrement : autoriser",
"Record policy: block": "Politique de l’enregistrement : bloquer",
"Record the evidence checked, the conclusion, and any follow-up action.": "Consignez les preuves vérifiées, la conclusion et toute action de suivi.",
"Review and close": "Examiner et fermer",
+ "Review and close selected ({{count}})": "Examiner et fermer la sélection ({{count}})",
"Review and close operational alerts without changing the underlying pending or manual financial settlement.": "Examinez et fermez les alertes opérationnelles sans modifier le règlement financier sous-jacent en attente ou manuel.",
"Review billing reconciliation": "Examiner le rapprochement de facturation",
"Review note": "Note d’examen",
@@ -5557,7 +5565,13 @@
"Reviewed record: {{count}}": "Enregistrement examiné : {{count}}",
"Reviewed records: {{count}}": "Enregistrements examinés : {{count}}",
"Save review": "Enregistrer l’examen",
+ "Select all billing reconciliation alerts": "Sélectionner toutes les alertes de rapprochement de facturation",
+ "Select billing reconciliation alert {{id}}": "Sélectionner l’alerte de rapprochement de facturation {{id}}",
+ "Select one or more alerts and close them with one click. The underlying financial settlement record remains available for safe retry and audit.": "Sélectionnez une ou plusieurs alertes et fermez-les en un clic. L’enregistrement de règlement financier sous-jacent reste disponible pour une nouvelle tentative sûre et l’audit.",
+ "Selected alerts: {{count}}": "Alertes sélectionnées : {{count}}",
+ "Showing the oldest {{count}} alerts; the summary covers all {{total}} open alerts.": "Affichage des {{count}} alertes les plus anciennes ; le résumé couvre les {{total}} alertes ouvertes.",
"There are no pending or manual positive final settlements requiring operator review.": "Aucun règlement final positif en attente ou manuel ne nécessite d’examen opérateur.",
+ "There are no pending or manual positive final settlements waiting for administrator review.": "Aucun règlement final positif en attente ou manuel n’attend l’examen d’un administrateur.",
"This choice affects only new paid-request admission while the financial settlement remains pending or manual.": "Ce choix affecte uniquement l’admission de nouvelles requêtes payantes tant que le règlement financier reste en attente ou manuel.",
"This process is not currently reporting sustained host pressure or an open billing reconciliation alert.": "Ce processus ne signale actuellement aucune pression persistante sur l’hôte ni aucune alerte ouverte de rapprochement de facturation.",
"User access": "Accès utilisateur",
diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json
index 4282729a..76112214 100644
--- a/web/default/src/i18n/locales/ja.json
+++ b/web/default/src/i18n/locales/ja.json
@@ -5526,6 +5526,7 @@
"Administrator review": "管理者確認",
"Allow user to continue": "ユーザーの利用継続を許可",
"Billing reconciliation alert closed.": "請求照合アラートを閉じました。",
+ "Billing reconciliation alerts closed: {{count}}": "課金照合アラートを閉じました:{{count}}件",
"Billing reconciliation controls": "請求照合コントロール",
"Block affected users by default": "影響を受けるユーザーをデフォルトでブロック",
"Close alert": "アラートを閉じる",
@@ -5534,10 +5535,13 @@
"Default user-blocking policy updated.": "デフォルトのユーザーブロックポリシーを更新しました。",
"Edit reconciliation review": "照合確認を編集",
"Edit review": "確認を編集",
+ "Failed to close reconciliation alerts.": "照合アラートを閉じられませんでした。",
"Failed to save review.": "確認を保存できませんでした。",
"Failed to update blocking policy.": "ブロックポリシーを更新できませんでした。",
"Financial / alert status": "財務 / アラート状態",
+ "Financial status": "財務ステータス",
"Last error": "直近のエラー",
+ "No open reconciliation alerts.": "未対応の照合アラートはありません。",
"No unresolved reconciliation records.": "未解決の照合レコードはありません。",
"Node": "ノード",
"Not reviewed": "未確認",
@@ -5545,10 +5549,14 @@
"Open alert": "未対応アラート",
"Open alert: {{count}}": "未対応アラート:{{count}}",
"Open alerts: {{count}}": "未対応アラート:{{count}}",
+ "Open billing reconciliation alerts": "未対応の課金照合アラート",
+ "Open manual settlements: {{count}}": "未対応の手動精算:{{count}}件",
+ "Open pending settlements: {{count}}": "未対応の保留中精算:{{count}}件",
"Record policy: allow": "レコードポリシー:許可",
"Record policy: block": "レコードポリシー:ブロック",
"Record the evidence checked, the conclusion, and any follow-up action.": "確認した証拠、結論、今後の対応を記録してください。",
"Review and close": "確認して閉じる",
+ "Review and close selected ({{count}})": "選択項目を確認して閉じる({{count}}件)",
"Review and close operational alerts without changing the underlying pending or manual financial settlement.": "基になる保留中または手動処理の財務精算を変更せずに、運用アラートを確認して閉じます。",
"Review billing reconciliation": "請求照合を確認",
"Review note": "確認メモ",
@@ -5557,7 +5565,13 @@
"Reviewed record: {{count}}": "確認済みレコード:{{count}}",
"Reviewed records: {{count}}": "確認済みレコード:{{count}}",
"Save review": "確認を保存",
+ "Select all billing reconciliation alerts": "すべての課金照合アラートを選択",
+ "Select billing reconciliation alert {{id}}": "課金照合アラート {{id}} を選択",
+ "Select one or more alerts and close them with one click. The underlying financial settlement record remains available for safe retry and audit.": "1件以上のアラートを選択し、ワンクリックで確認して閉じます。基礎となる財務精算レコードは、安全な再試行と監査のため保持されます。",
+ "Selected alerts: {{count}}": "選択したアラート:{{count}}件",
+ "Showing the oldest {{count}} alerts; the summary covers all {{total}} open alerts.": "古い順に{{count}}件のアラートを表示しています。概要は未対応の全{{total}}件を対象とします。",
"There are no pending or manual positive final settlements requiring operator review.": "管理者による確認が必要な保留中または手動処理の正の最終精算はありません。",
+ "There are no pending or manual positive final settlements waiting for administrator review.": "管理者の確認を待つ保留中または手動の正の最終精算はありません。",
"This choice affects only new paid-request admission while the financial settlement remains pending or manual.": "この選択は、財務精算が保留中または手動処理の間における新規有料リクエストの受付だけに影響します。",
"This process is not currently reporting sustained host pressure or an open billing reconciliation alert.": "このプロセスでは現在、継続的なホスト負荷や未対応の請求照合アラートは報告されていません。",
"User access": "ユーザーアクセス",
diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json
index e442d2b7..e8e7dfa6 100644
--- a/web/default/src/i18n/locales/ru.json
+++ b/web/default/src/i18n/locales/ru.json
@@ -5526,6 +5526,7 @@
"Administrator review": "Проверка администратором",
"Allow user to continue": "Разрешить пользователю продолжить работу",
"Billing reconciliation alert closed.": "Оповещение о сверке биллинга закрыто.",
+ "Billing reconciliation alerts closed: {{count}}": "Закрыто оповещений о сверке расчётов: {{count}}",
"Billing reconciliation controls": "Управление сверкой биллинга",
"Block affected users by default": "По умолчанию блокировать затронутых пользователей",
"Close alert": "Закрыть оповещение",
@@ -5534,10 +5535,13 @@
"Default user-blocking policy updated.": "Политика блокировки пользователей по умолчанию обновлена.",
"Edit reconciliation review": "Изменить проверку сверки",
"Edit review": "Изменить проверку",
+ "Failed to close reconciliation alerts.": "Не удалось закрыть оповещения о сверке.",
"Failed to save review.": "Не удалось сохранить проверку.",
"Failed to update blocking policy.": "Не удалось обновить политику блокировки.",
"Financial / alert status": "Финансовое состояние / состояние оповещения",
+ "Financial status": "Финансовое состояние",
"Last error": "Последняя ошибка",
+ "No open reconciliation alerts.": "Нет открытых оповещений о сверке.",
"No unresolved reconciliation records.": "Нет неразрешённых записей сверки.",
"Node": "Узел",
"Not reviewed": "Не проверено",
@@ -5545,10 +5549,14 @@
"Open alert": "Открытое оповещение",
"Open alert: {{count}}": "Открытое оповещение: {{count}}",
"Open alerts: {{count}}": "Открытых оповещений: {{count}}",
+ "Open billing reconciliation alerts": "Открытые оповещения о сверке расчётов",
+ "Open manual settlements: {{count}}": "Открытые расчёты на ручной обработке: {{count}}",
+ "Open pending settlements: {{count}}": "Открытые расчёты в ожидании: {{count}}",
"Record policy: allow": "Политика записи: разрешить",
"Record policy: block": "Политика записи: блокировать",
"Record the evidence checked, the conclusion, and any follow-up action.": "Зафиксируйте проверенные данные, вывод и последующие действия.",
"Review and close": "Проверить и закрыть",
+ "Review and close selected ({{count}})": "Проверить и закрыть выбранные ({{count}})",
"Review and close operational alerts without changing the underlying pending or manual financial settlement.": "Проверяйте и закрывайте операционные оповещения, не изменяя соответствующий финансовый расчёт в статусе «ожидание» или «ручная обработка».",
"Review billing reconciliation": "Проверить сверку биллинга",
"Review note": "Примечание к проверке",
@@ -5557,7 +5565,13 @@
"Reviewed record: {{count}}": "Проверено записей: {{count}}",
"Reviewed records: {{count}}": "Проверено записей: {{count}}",
"Save review": "Сохранить проверку",
+ "Select all billing reconciliation alerts": "Выбрать все оповещения о сверке расчётов",
+ "Select billing reconciliation alert {{id}}": "Выбрать оповещение о сверке расчётов {{id}}",
+ "Select one or more alerts and close them with one click. The underlying financial settlement record remains available for safe retry and audit.": "Выберите одно или несколько оповещений и закройте их одним нажатием. Исходная запись финансового расчёта сохраняется для безопасного повтора и аудита.",
+ "Selected alerts: {{count}}": "Выбрано оповещений: {{count}}",
+ "Showing the oldest {{count}} alerts; the summary covers all {{total}} open alerts.": "Показаны самые старые оповещения: {{count}}; сводка охватывает все открытые оповещения: {{total}}.",
"There are no pending or manual positive final settlements requiring operator review.": "Нет положительных окончательных расчётов в статусе «ожидание» или «ручная обработка», требующих проверки оператором.",
+ "There are no pending or manual positive final settlements waiting for administrator review.": "Нет ожидающих проверки администратором положительных итоговых расчётов в состоянии ожидания или ручной обработки.",
"This choice affects only new paid-request admission while the financial settlement remains pending or manual.": "Этот выбор влияет только на допуск новых платных запросов, пока финансовый расчёт имеет статус «ожидание» или «ручная обработка».",
"This process is not currently reporting sustained host pressure or an open billing reconciliation alert.": "Сейчас этот процесс не сообщает о длительной нагрузке на узел или открытом оповещении сверки биллинга.",
"User access": "Доступ пользователя",
diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json
index d6c46782..1ac3ccd5 100644
--- a/web/default/src/i18n/locales/vi.json
+++ b/web/default/src/i18n/locales/vi.json
@@ -5526,6 +5526,7 @@
"Administrator review": "Kiểm tra của quản trị viên",
"Allow user to continue": "Cho phép người dùng tiếp tục",
"Billing reconciliation alert closed.": "Đã đóng cảnh báo đối soát tính phí.",
+ "Billing reconciliation alerts closed: {{count}}": "Đã đóng cảnh báo đối soát tính phí: {{count}}",
"Billing reconciliation controls": "Kiểm soát đối soát tính phí",
"Block affected users by default": "Mặc định chặn người dùng bị ảnh hưởng",
"Close alert": "Đóng cảnh báo",
@@ -5534,10 +5535,13 @@
"Default user-blocking policy updated.": "Đã cập nhật chính sách chặn người dùng mặc định.",
"Edit reconciliation review": "Chỉnh sửa kiểm tra đối soát",
"Edit review": "Chỉnh sửa kiểm tra",
+ "Failed to close reconciliation alerts.": "Không thể đóng các cảnh báo đối soát.",
"Failed to save review.": "Không thể lưu kết quả kiểm tra.",
"Failed to update blocking policy.": "Không thể cập nhật chính sách chặn.",
"Financial / alert status": "Trạng thái tài chính / cảnh báo",
+ "Financial status": "Trạng thái tài chính",
"Last error": "Lỗi gần nhất",
+ "No open reconciliation alerts.": "Không có cảnh báo đối soát đang mở.",
"No unresolved reconciliation records.": "Không có bản ghi đối soát chưa xử lý.",
"Node": "Nút",
"Not reviewed": "Chưa kiểm tra",
@@ -5545,10 +5549,14 @@
"Open alert": "Cảnh báo đang mở",
"Open alert: {{count}}": "Cảnh báo đang mở: {{count}}",
"Open alerts: {{count}}": "Cảnh báo đang mở: {{count}}",
+ "Open billing reconciliation alerts": "Cảnh báo đối soát tính phí đang mở",
+ "Open manual settlements: {{count}}": "Quyết toán thủ công có cảnh báo mở: {{count}}",
+ "Open pending settlements: {{count}}": "Quyết toán đang chờ có cảnh báo mở: {{count}}",
"Record policy: allow": "Chính sách bản ghi: cho phép",
"Record policy: block": "Chính sách bản ghi: chặn",
"Record the evidence checked, the conclusion, and any follow-up action.": "Ghi lại bằng chứng đã kiểm tra, kết luận và hành động tiếp theo.",
"Review and close": "Kiểm tra và đóng",
+ "Review and close selected ({{count}})": "Kiểm tra và đóng mục đã chọn ({{count}})",
"Review and close operational alerts without changing the underlying pending or manual financial settlement.": "Kiểm tra và đóng cảnh báo vận hành mà không thay đổi quyết toán tài chính tương ứng đang chờ hoặc xử lý thủ công.",
"Review billing reconciliation": "Kiểm tra đối soát tính phí",
"Review note": "Ghi chú kiểm tra",
@@ -5557,7 +5565,13 @@
"Reviewed record: {{count}}": "Đã kiểm tra bản ghi: {{count}}",
"Reviewed records: {{count}}": "Đã kiểm tra bản ghi: {{count}}",
"Save review": "Lưu kết quả kiểm tra",
+ "Select all billing reconciliation alerts": "Chọn tất cả cảnh báo đối soát tính phí",
+ "Select billing reconciliation alert {{id}}": "Chọn cảnh báo đối soát tính phí {{id}}",
+ "Select one or more alerts and close them with one click. The underlying financial settlement record remains available for safe retry and audit.": "Chọn một hoặc nhiều cảnh báo và đóng chúng bằng một lần nhấp. Bản ghi quyết toán tài chính nền tảng vẫn được giữ lại để thử lại an toàn và kiểm toán.",
+ "Selected alerts: {{count}}": "Cảnh báo đã chọn: {{count}}",
+ "Showing the oldest {{count}} alerts; the summary covers all {{total}} open alerts.": "Đang hiển thị {{count}} cảnh báo cũ nhất; phần tóm tắt bao gồm toàn bộ {{total}} cảnh báo đang mở.",
"There are no pending or manual positive final settlements requiring operator review.": "Không có quyết toán cuối có số tiền dương nào đang chờ hoặc xử lý thủ công cần quản trị viên kiểm tra.",
+ "There are no pending or manual positive final settlements waiting for administrator review.": "Không có quyết toán cuối dương đang chờ hoặc cần xử lý thủ công nào chờ quản trị viên kiểm tra.",
"This choice affects only new paid-request admission while the financial settlement remains pending or manual.": "Lựa chọn này chỉ ảnh hưởng đến việc tiếp nhận yêu cầu có tính phí mới trong khi quyết toán tài chính vẫn ở trạng thái đang chờ hoặc xử lý thủ công.",
"This process is not currently reporting sustained host pressure or an open billing reconciliation alert.": "Tiến trình này hiện không báo cáo áp lực máy chủ kéo dài hoặc cảnh báo đối soát tính phí đang mở.",
"User access": "Quyền truy cập của người dùng",
diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json
index 313972c3..484b8b53 100644
--- a/web/default/src/i18n/locales/zh.json
+++ b/web/default/src/i18n/locales/zh.json
@@ -5526,6 +5526,7 @@
"Administrator review": "管理员核对",
"Allow user to continue": "允许用户继续使用",
"Billing reconciliation alert closed.": "计费对账告警已关闭。",
+ "Billing reconciliation alerts closed: {{count}}": "已关闭计费对账告警:{{count}}",
"Billing reconciliation controls": "计费对账控制",
"Block affected users by default": "默认阻止受影响用户",
"Close alert": "关闭告警",
@@ -5534,10 +5535,13 @@
"Default user-blocking policy updated.": "默认用户阻止策略已更新。",
"Edit reconciliation review": "编辑对账核对",
"Edit review": "编辑核对",
+ "Failed to close reconciliation alerts.": "关闭对账告警失败。",
"Failed to save review.": "保存核对失败。",
"Failed to update blocking policy.": "更新阻止策略失败。",
"Financial / alert status": "财务 / 告警状态",
+ "Financial status": "财务状态",
"Last error": "最近错误",
+ "No open reconciliation alerts.": "没有未关闭的对账告警。",
"No unresolved reconciliation records.": "没有未解决的对账记录。",
"Node": "节点",
"Not reviewed": "未核对",
@@ -5545,10 +5549,14 @@
"Open alert": "打开告警",
"Open alert: {{count}}": "未关闭告警:{{count}}",
"Open alerts: {{count}}": "未关闭告警:{{count}}",
+ "Open billing reconciliation alerts": "未关闭的计费对账告警",
+ "Open manual settlements: {{count}}": "未关闭的需人工处理结算:{{count}}",
+ "Open pending settlements: {{count}}": "未关闭的待结算:{{count}}",
"Record policy: allow": "记录策略:允许",
"Record policy: block": "记录策略:阻止",
"Record the evidence checked, the conclusion, and any follow-up action.": "记录已核查的证据、结论和后续操作。",
"Review and close": "核对并关闭",
+ "Review and close selected ({{count}})": "核对并关闭所选告警({{count}})",
"Review and close operational alerts without changing the underlying pending or manual financial settlement.": "核对并关闭运维告警,不更改底层待处理或人工处理财务结算。",
"Review billing reconciliation": "核对计费对账",
"Review note": "核对备注",
@@ -5557,7 +5565,13 @@
"Reviewed record: {{count}}": "已核对记录:{{count}}",
"Reviewed records: {{count}}": "已核对记录:{{count}}",
"Save review": "保存核对",
+ "Select all billing reconciliation alerts": "选择全部计费对账告警",
+ "Select billing reconciliation alert {{id}}": "选择计费对账告警 {{id}}",
+ "Select one or more alerts and close them with one click. The underlying financial settlement record remains available for safe retry and audit.": "选择一条或多条告警,点击一次即可核对并关闭。底层财务结算记录仍会保留,用于安全重试和审计。",
+ "Selected alerts: {{count}}": "已选择告警:{{count}}",
+ "Showing the oldest {{count}} alerts; the summary covers all {{total}} open alerts.": "当前显示最早的 {{count}} 条告警;摘要涵盖全部 {{total}} 条未关闭告警。",
"There are no pending or manual positive final settlements requiring operator review.": "没有需要管理员核对的待处理或人工处理正向最终结算。",
+ "There are no pending or manual positive final settlements waiting for administrator review.": "没有等待管理员核对的待结算或需人工处理的正向最终结算。",
"This choice affects only new paid-request admission while the financial settlement remains pending or manual.": "此选择只影响财务结算保持待处理或人工处理期间的新付费请求准入。",
"This process is not currently reporting sustained host pressure or an open billing reconciliation alert.": "当前进程未报告持续的主机资源压力或未关闭的计费对账告警。",
"User access": "用户访问状态",
From 9ea375912996e9a9520e5df9fd9b890a5e53c4a0 Mon Sep 17 00:00:00 2001
From: CSCITech
Date: Tue, 1 Sep 2026 00:12:16 +0800
Subject: [PATCH 5/8] fix: harden billing review batches
---
model/billing_settlement.go | 7 ++++++-
model/billing_settlement_regression_test.go | 10 +++++++---
.../components/billing-settlement-evidence.tsx | 2 +-
.../components/billing-settlement-table.tsx | 4 ++--
.../src/features/smart-ops/lib/format.test.ts | 16 ++++++++++++++++
web/default/src/i18n/locales/ja.json | 8 ++++----
web/default/src/i18n/locales/ru.json | 8 ++++----
7 files changed, 40 insertions(+), 15 deletions(-)
diff --git a/model/billing_settlement.go b/model/billing_settlement.go
index d0964f99..9e4aaed8 100644
--- a/model/billing_settlement.go
+++ b/model/billing_settlement.go
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
+ "sort"
"sync"
"time"
@@ -549,11 +550,15 @@ func ReviewBillingSettlements(targets []BillingSettlementReviewTarget, reviewerI
}
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()
- for _, target := range targets {
+ for _, target := range sortedTargets {
result := openPositiveFinalizeSettlementAlertScope(tx).
Where("id = ? AND revision = ?", target.ID, target.Revision).
UpdateColumns(billingSettlementReviewUpdates(reviewerID, false, "", reviewedAt))
diff --git a/model/billing_settlement_regression_test.go b/model/billing_settlement_regression_test.go
index 2209b285..4f1b87f8 100644
--- a/model/billing_settlement_regression_test.go
+++ b/model/billing_settlement_regression_test.go
@@ -324,14 +324,18 @@ func TestReviewBillingSettlementsClosesSelectionAtomically(t *testing.T) {
}
require.NoError(t, DB.Create(&records).Error)
- reviewed, err := ReviewBillingSettlements([]BillingSettlementReviewTarget{
- {ID: records[0].ID, Revision: records[0].Revision},
+ targets := []BillingSettlementReviewTarget{
{ID: records[1].ID, Revision: records[1].Revision},
- }, 7002)
+ {ID: records[0].ID, Revision: records[0].Revision},
+ }
+ reviewed, err := ReviewBillingSettlements(targets, 7002)
require.NoError(t, err)
require.Len(t, reviewed, 2)
+ assert.Equal(t, records[1].ID, targets[0].ID, "sorting must not mutate the caller's target order")
+ assert.Equal(t, records[0].ID, targets[1].ID)
for index := range reviewed {
+ assert.Equal(t, records[index].ID, reviewed[index].ID, "settlement locks must be acquired in ascending ID order")
assert.Positive(t, reviewed[index].ReconciliationReviewedAt)
assert.Equal(t, 7002, reviewed[index].ReconciliationReviewedBy)
assert.Empty(t, reviewed[index].ReconciliationReviewNote)
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 e195ea06..1ec33988 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
@@ -146,7 +146,7 @@ export function BillingSettlementEvidence(
},
})
- const reviewItems = (items: BillingSettlementReconciliationItem[]) => {
+ const reviewItems = (items: BillingSettlementReconciliationItem[]): void => {
if (items.length > 0 && !reviewMutation.isPending) {
reviewMutation.mutate(items)
}
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 63ed53af..5c80c784 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
@@ -79,13 +79,13 @@ export function BillingSettlementTable(
props.selectedIDs.has(item.id)
)
- const toggleAll = (checked: boolean) => {
+ const toggleAll = (checked: boolean): void => {
props.onSelectedIDsChange(
checked ? new Set(props.items.map((item) => item.id)) : new Set()
)
}
- const toggleItem = (id: number, checked: boolean) => {
+ const toggleItem = (id: number, checked: boolean): void => {
const next = new Set(props.selectedIDs)
if (checked) next.add(id)
else next.delete(id)
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 b87279f7..e01158b9 100644
--- a/web/default/src/features/smart-ops/lib/format.test.ts
+++ b/web/default/src/features/smart-ops/lib/format.test.ts
@@ -171,6 +171,22 @@ describe('formatLocalizedCount', () => {
vi.translation['Open billing reconciliation alerts'],
'Cảnh báo đối soát tính phí đang mở'
)
+ assert.equal(
+ ja.translation['Open billing reconciliation alerts'],
+ '未対応の請求照合アラート'
+ )
+ assert.equal(
+ ru.translation['Open billing reconciliation alerts'],
+ 'Открытые оповещения о сверке биллинга'
+ )
+ assert.doesNotMatch(
+ ja.translation['Billing reconciliation alerts closed: {{count}}'],
+ /課金照合/
+ )
+ assert.doesNotMatch(
+ ru.translation['Billing reconciliation alerts closed: {{count}}'],
+ /сверк[аеи] расчётов/i
+ )
assert.equal(
zh.translation['No open reconciliation alerts.'],
'没有未关闭的对账告警。'
diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json
index 76112214..e7ee2add 100644
--- a/web/default/src/i18n/locales/ja.json
+++ b/web/default/src/i18n/locales/ja.json
@@ -5526,7 +5526,7 @@
"Administrator review": "管理者確認",
"Allow user to continue": "ユーザーの利用継続を許可",
"Billing reconciliation alert closed.": "請求照合アラートを閉じました。",
- "Billing reconciliation alerts closed: {{count}}": "課金照合アラートを閉じました:{{count}}件",
+ "Billing reconciliation alerts closed: {{count}}": "請求照合アラートを閉じました:{{count}}件",
"Billing reconciliation controls": "請求照合コントロール",
"Block affected users by default": "影響を受けるユーザーをデフォルトでブロック",
"Close alert": "アラートを閉じる",
@@ -5549,7 +5549,7 @@
"Open alert": "未対応アラート",
"Open alert: {{count}}": "未対応アラート:{{count}}",
"Open alerts: {{count}}": "未対応アラート:{{count}}",
- "Open billing reconciliation alerts": "未対応の課金照合アラート",
+ "Open billing reconciliation alerts": "未対応の請求照合アラート",
"Open manual settlements: {{count}}": "未対応の手動精算:{{count}}件",
"Open pending settlements: {{count}}": "未対応の保留中精算:{{count}}件",
"Record policy: allow": "レコードポリシー:許可",
@@ -5565,8 +5565,8 @@
"Reviewed record: {{count}}": "確認済みレコード:{{count}}",
"Reviewed records: {{count}}": "確認済みレコード:{{count}}",
"Save review": "確認を保存",
- "Select all billing reconciliation alerts": "すべての課金照合アラートを選択",
- "Select billing reconciliation alert {{id}}": "課金照合アラート {{id}} を選択",
+ "Select all billing reconciliation alerts": "すべての請求照合アラートを選択",
+ "Select billing reconciliation alert {{id}}": "請求照合アラート {{id}} を選択",
"Select one or more alerts and close them with one click. The underlying financial settlement record remains available for safe retry and audit.": "1件以上のアラートを選択し、ワンクリックで確認して閉じます。基礎となる財務精算レコードは、安全な再試行と監査のため保持されます。",
"Selected alerts: {{count}}": "選択したアラート:{{count}}件",
"Showing the oldest {{count}} alerts; the summary covers all {{total}} open alerts.": "古い順に{{count}}件のアラートを表示しています。概要は未対応の全{{total}}件を対象とします。",
diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json
index e8e7dfa6..32b4e169 100644
--- a/web/default/src/i18n/locales/ru.json
+++ b/web/default/src/i18n/locales/ru.json
@@ -5526,7 +5526,7 @@
"Administrator review": "Проверка администратором",
"Allow user to continue": "Разрешить пользователю продолжить работу",
"Billing reconciliation alert closed.": "Оповещение о сверке биллинга закрыто.",
- "Billing reconciliation alerts closed: {{count}}": "Закрыто оповещений о сверке расчётов: {{count}}",
+ "Billing reconciliation alerts closed: {{count}}": "Закрыто оповещений о сверке биллинга: {{count}}",
"Billing reconciliation controls": "Управление сверкой биллинга",
"Block affected users by default": "По умолчанию блокировать затронутых пользователей",
"Close alert": "Закрыть оповещение",
@@ -5549,7 +5549,7 @@
"Open alert": "Открытое оповещение",
"Open alert: {{count}}": "Открытое оповещение: {{count}}",
"Open alerts: {{count}}": "Открытых оповещений: {{count}}",
- "Open billing reconciliation alerts": "Открытые оповещения о сверке расчётов",
+ "Open billing reconciliation alerts": "Открытые оповещения о сверке биллинга",
"Open manual settlements: {{count}}": "Открытые расчёты на ручной обработке: {{count}}",
"Open pending settlements: {{count}}": "Открытые расчёты в ожидании: {{count}}",
"Record policy: allow": "Политика записи: разрешить",
@@ -5565,8 +5565,8 @@
"Reviewed record: {{count}}": "Проверено записей: {{count}}",
"Reviewed records: {{count}}": "Проверено записей: {{count}}",
"Save review": "Сохранить проверку",
- "Select all billing reconciliation alerts": "Выбрать все оповещения о сверке расчётов",
- "Select billing reconciliation alert {{id}}": "Выбрать оповещение о сверке расчётов {{id}}",
+ "Select all billing reconciliation alerts": "Выбрать все оповещения о сверке биллинга",
+ "Select billing reconciliation alert {{id}}": "Выбрать оповещение о сверке биллинга {{id}}",
"Select one or more alerts and close them with one click. The underlying financial settlement record remains available for safe retry and audit.": "Выберите одно или несколько оповещений и закройте их одним нажатием. Исходная запись финансового расчёта сохраняется для безопасного повтора и аудита.",
"Selected alerts: {{count}}": "Выбрано оповещений: {{count}}",
"Showing the oldest {{count}} alerts; the summary covers all {{total}} open alerts.": "Показаны самые старые оповещения: {{count}}; сводка охватывает все открытые оповещения: {{total}}.",
From 615510574d6b218e8c5e46a8ee1e93321b6755dc Mon Sep 17 00:00:00 2001
From: CSCITech
Date: Tue, 1 Sep 2026 01:22:03 +0800
Subject: [PATCH 6/8] fix: preserve billing review snapshots
---
model/billing_settlement.go | 14 +-
model/billing_settlement_regression_test.go | 36 ++++++
.../src/features/smart-ops/alerts.test.tsx | 121 ++++++++++++++++++
.../billing-settlement-evidence.tsx | 54 +++++---
.../components/billing-settlement-table.tsx | 59 ++++++---
.../src/features/smart-ops/lib/format.test.ts | 2 +-
web/default/src/features/smart-ops/types.ts | 10 +-
web/default/src/i18n/locales/zh.json | 2 +-
8 files changed, 247 insertions(+), 51 deletions(-)
diff --git a/model/billing_settlement.go b/model/billing_settlement.go
index 9e4aaed8..b9146e69 100644
--- a/model/billing_settlement.go
+++ b/model/billing_settlement.go
@@ -558,6 +558,7 @@ func ReviewBillingSettlements(targets []BillingSettlementReviewTarget, reviewerI
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).
@@ -568,12 +569,13 @@ func ReviewBillingSettlements(targets []BillingSettlementReviewTarget, reviewerI
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)
+ updatedIDs = append(updatedIDs, target.ID)
+ }
+ if err := tx.Where("id IN ?", updatedIDs).Order("id ASC").Find(&reviewed).Error; err != nil {
+ return err
+ }
+ if len(reviewed) != len(updatedIDs) {
+ return gorm.ErrRecordNotFound
}
return nil
})
diff --git a/model/billing_settlement_regression_test.go b/model/billing_settlement_regression_test.go
index 4f1b87f8..b913e4b2 100644
--- a/model/billing_settlement_regression_test.go
+++ b/model/billing_settlement_regression_test.go
@@ -323,6 +323,12 @@ func TestReviewBillingSettlementsClosesSelectionAtomically(t *testing.T) {
},
}
require.NoError(t, DB.Create(&records).Error)
+ queryCount := 0
+ callbackName := "test:batch-review-single-read"
+ require.NoError(t, DB.Callback().Query().Before("gorm:query").Register(callbackName, func(_ *gorm.DB) {
+ queryCount++
+ }))
+ t.Cleanup(func() { DB.Callback().Query().Remove(callbackName) })
targets := []BillingSettlementReviewTarget{
{ID: records[1].ID, Revision: records[1].Revision},
@@ -332,6 +338,7 @@ func TestReviewBillingSettlementsClosesSelectionAtomically(t *testing.T) {
require.NoError(t, err)
require.Len(t, reviewed, 2)
+ assert.Equal(t, 1, queryCount, "reviewed settlements must be read back in one query")
assert.Equal(t, records[1].ID, targets[0].ID, "sorting must not mutate the caller's target order")
assert.Equal(t, records[0].ID, targets[1].ID)
for index := range reviewed {
@@ -390,6 +397,35 @@ func TestReviewBillingSettlementsRejectsStaleBatchWithoutPartialClose(t *testing
}
}
+func TestReviewBillingSettlementsRollsBackWhenBatchReadFails(t *testing.T) {
+ setupUserUpdateTestState(t)
+
+ now := time.Now().Unix()
+ record := BillingSettlement{
+ OperationKey: "request:batch-review-read-failure:finalize", Source: BillingSettlementSourceWallet,
+ UserID: 977, FundingDelta: 10, TokenDelta: 10, Status: BillingSettlementStatusPending,
+ CreatedAt: now - 10, UpdatedAt: now - 5, Revision: 2,
+ }
+ require.NoError(t, DB.Create(&record).Error)
+
+ callbackName := "test:batch-review-read-failure"
+ readErr := errors.New("forced batch review read failure")
+ require.NoError(t, DB.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) {
+ tx.AddError(readErr)
+ }))
+ _, err := ReviewBillingSettlements([]BillingSettlementReviewTarget{{
+ ID: record.ID, Revision: record.Revision,
+ }}, 7004)
+ require.NoError(t, DB.Callback().Query().Remove(callbackName))
+
+ assert.ErrorIs(t, err, readErr)
+ var stored BillingSettlement
+ require.NoError(t, DB.First(&stored, record.ID).Error)
+ assert.Zero(t, stored.ReconciliationReviewedAt)
+ assert.Zero(t, stored.ReconciliationReviewedBy)
+ assert.Nil(t, stored.UserBlockingOverride)
+}
+
func TestBillingSettlementBlockingScopeDoesNotLeakAcrossUsers(t *testing.T) {
setupUserUpdateTestState(t)
setBillingReconciliationBlockDefaultForTest(t, true)
diff --git a/web/default/src/features/smart-ops/alerts.test.tsx b/web/default/src/features/smart-ops/alerts.test.tsx
index 1877c847..20734868 100644
--- a/web/default/src/features/smart-ops/alerts.test.tsx
+++ b/web/default/src/features/smart-ops/alerts.test.tsx
@@ -538,6 +538,127 @@ describe('SmartOps active alerts', () => {
}
})
+ test('clears a selected alert when refresh changes its financial revision', async (): Promise => {
+ const originalGet = api.get
+ const originalPost = api.post
+ const writes: Array<{ url: string; data: unknown }> = []
+ let revision = 4
+ api.get = (async (url: string): Promise => {
+ if (url !== '/api/smart-ops/billing-settlements') {
+ return { data: { success: true, data: [] } }
+ }
+ return {
+ data: {
+ success: true,
+ data: {
+ ...emptyReconciliationData(),
+ total_count: 1,
+ pending_count: 1,
+ open_alert_count: 1,
+ items: [
+ {
+ id: 91,
+ revision,
+ operation_key: 'request:billing-request-91:finalize',
+ status: 'pending',
+ source: 'wallet',
+ user_id: 51,
+ subscription_id: 0,
+ token_id: 0,
+ task_id: 0,
+ funding_delta: 100,
+ applied_funding_delta: 0,
+ token_delta: 100,
+ applied_token_delta: 0,
+ attempts: 2,
+ last_error: 'quota changed',
+ next_attempt: 1788106500,
+ created_at: 1786032544,
+ updated_at: 1786032544,
+ reconciliation_reviewed_at: 0,
+ reconciliation_reviewed_by: 0,
+ reconciliation_review_note: '',
+ user_blocking_override: null,
+ record_blocks_user: false,
+ blocks_user: false,
+ },
+ ],
+ },
+ },
+ }
+ }) as typeof api.get
+ api.post = (async (url: string, data: unknown): Promise => {
+ writes.push({ url: String(url), data })
+ return { data: { success: true } }
+ }) as typeof api.post
+
+ const queryClient = createQueryClient()
+ const view = await testEnv.render(
+
+
+
+ )
+
+ try {
+ let rowSelection: HTMLElement | undefined
+ await waitFor(() => {
+ rowSelection = within(view.container).getByRole('checkbox', {
+ name: 'Select billing reconciliation alert 91',
+ })
+ })
+ assert.ok(rowSelection)
+ await view.click(rowSelection)
+ await waitFor(() => {
+ assert.ok(
+ within(view.container).getByRole('button', {
+ name: 'Review and close selected (1)',
+ })
+ )
+ })
+
+ revision = 6
+ await queryClient.invalidateQueries()
+ await waitFor(() => {
+ const closeSelectedButton = within(view.container).getByRole('button', {
+ name: 'Review and close selected (0)',
+ })
+ assert.equal(closeSelectedButton.hasAttribute('disabled'), true)
+ assert.equal(
+ within(view.container)
+ .getByRole('checkbox', {
+ name: 'Select billing reconciliation alert 91',
+ })
+ .getAttribute('data-checked'),
+ null
+ )
+ })
+ assert.equal(writes.length, 0)
+
+ rowSelection = within(view.container).getByRole('checkbox', {
+ name: 'Select billing reconciliation alert 91',
+ })
+ await view.click(rowSelection)
+ await view.click(
+ within(view.container).getByRole('button', {
+ name: 'Review and close selected (1)',
+ })
+ )
+ await waitFor(() => {
+ assert.deepEqual(writes, [
+ {
+ url: '/api/smart-ops/billing-settlements/reviews',
+ data: { items: [{ id: 91, revision: 6 }] },
+ },
+ ])
+ })
+ } finally {
+ api.get = originalGet
+ api.post = originalPost
+ await view.unmount()
+ queryClient.clear()
+ }
+ })
+
test('keeps the global blocking policy read only for non-root administrators', async (): Promise => {
const originalUser = useAuthStore.getState().auth.user
useAuthStore.getState().auth.setUser({
diff --git a/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx b/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx
index 1ec33988..4606aa0d 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
@@ -46,7 +46,7 @@ import {
} from '../lib/query-keys'
import type {
BillingSettlementReconciliationData,
- BillingSettlementReconciliationItem,
+ BillingSettlementReviewTarget,
} from '../types'
import { BillingSettlementTable } from './billing-settlement-table'
@@ -62,7 +62,9 @@ export function BillingSettlementEvidence(
props: BillingSettlementEvidenceProps
): ReactElement {
const { t, i18n } = useTranslation()
- const [selectedIDs, setSelectedIDs] = useState>(() => new Set())
+ const [selectedTargets, setSelectedTargets] = useState<
+ Map
+ >(() => new Map())
const [policyOverride, setPolicyOverride] = useState(null)
const queryClient = useQueryClient()
const policyValue =
@@ -109,19 +111,17 @@ export function BillingSettlementEvidence(
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 })),
- })
+ mutationFn: async (targets: BillingSettlementReviewTarget[]) => {
+ const response = await reviewBillingSettlements({ items: targets })
if (!response.success) {
throw new Error(
response.message || t('Failed to close reconciliation alerts.')
)
}
- return items.length
+ return targets.length
},
onSuccess: async (count) => {
- setSelectedIDs(new Set())
+ setSelectedTargets(new Map())
toast.success(
t('Billing reconciliation alerts closed: {{count}}', { count })
)
@@ -146,12 +146,18 @@ export function BillingSettlementEvidence(
},
})
- const reviewItems = (items: BillingSettlementReconciliationItem[]): void => {
- if (items.length > 0 && !reviewMutation.isPending) {
- reviewMutation.mutate(items)
+ const reviewTargets = (targets: BillingSettlementReviewTarget[]): void => {
+ if (targets.length > 0 && !reviewMutation.isPending) {
+ reviewMutation.mutate(targets)
}
}
+ const replaceSelectedTargets = (
+ targets: Map
+ ): void => {
+ setSelectedTargets(targets)
+ }
+
if (props.loading) {
return (
@@ -188,8 +194,14 @@ export function BillingSettlementEvidence(
return <>>
}
- const selectedItems = props.data.items.filter((item) =>
- selectedIDs.has(item.id)
+ const currentRevisions = new Map(
+ props.data.items.map((item) => [item.id, item.revision])
+ )
+ const activeSelectedTargets = Array.from(selectedTargets.values()).filter(
+ (target) => currentRevisions.get(target.id) === target.revision
+ )
+ const activeSelectedTargetMap = new Map(
+ activeSelectedTargets.map((target) => [target.id, target])
)
return (
@@ -283,14 +295,16 @@ export function BillingSettlementEvidence(
{t('Selected alerts: {{count}}', {
- count: formatCount(selectedItems.length, i18n.language),
+ count: formatCount(activeSelectedTargets.length, i18n.language),
})}
reviewItems(selectedItems)}
- disabled={selectedItems.length === 0 || reviewMutation.isPending}
+ onClick={() => reviewTargets(activeSelectedTargets)}
+ disabled={
+ activeSelectedTargets.length === 0 || reviewMutation.isPending
+ }
>
{reviewMutation.isPending && (
)}
{t('Review and close selected ({{count}})', {
- count: formatCount(selectedItems.length, i18n.language),
+ count: formatCount(activeSelectedTargets.length, i18n.language),
})}
)}
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 5c80c784..9bd0bfbd 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
@@ -36,7 +36,10 @@ import {
TableRow,
} from '@/components/ui/table'
import { formatLocalizedCount } from '../lib/format'
-import type { BillingSettlementReconciliationItem } from '../types'
+import type {
+ BillingSettlementReconciliationItem,
+ BillingSettlementReviewTarget,
+} from '../types'
const FUNDING_SOURCE_LABEL: Record = {
wallet: 'Wallet',
@@ -62,34 +65,48 @@ function settlementReferences(
interface BillingSettlementTableProps {
items: BillingSettlementReconciliationItem[]
- selectedIDs: ReadonlySet
+ selectedTargets: ReadonlyMap
reviewPending: boolean
- onSelectedIDsChange: (ids: Set) => void
- onReviewItems: (items: BillingSettlementReconciliationItem[]) => void
+ onSelectedTargetsChange: (
+ targets: Map
+ ) => void
+ onReviewTargets: (targets: BillingSettlementReviewTarget[]) => void
}
export function BillingSettlementTable(
props: BillingSettlementTableProps
): ReactElement {
const { t, i18n } = useTranslation()
+ const isSelected = (item: BillingSettlementReconciliationItem): boolean =>
+ props.selectedTargets.get(item.id)?.revision === item.revision
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)
- )
+ props.items.length > 0 && props.items.every((item) => isSelected(item))
+ const someSelected = props.items.some((item) => isSelected(item))
const toggleAll = (checked: boolean): void => {
- props.onSelectedIDsChange(
- checked ? new Set(props.items.map((item) => item.id)) : new Set()
+ props.onSelectedTargetsChange(
+ checked
+ ? new Map(
+ props.items.map((item) => [
+ item.id,
+ { id: item.id, revision: item.revision },
+ ])
+ )
+ : new Map()
)
}
- const toggleItem = (id: number, checked: boolean): void => {
- const next = new Set(props.selectedIDs)
- if (checked) next.add(id)
- else next.delete(id)
- props.onSelectedIDsChange(next)
+ const toggleItem = (
+ item: BillingSettlementReconciliationItem,
+ checked: boolean
+ ): void => {
+ const next = new Map(props.selectedTargets)
+ if (checked) {
+ next.set(item.id, { id: item.id, revision: item.revision })
+ } else {
+ next.delete(item.id)
+ }
+ props.onSelectedTargetsChange(next)
}
return (
@@ -129,9 +146,9 @@ export function BillingSettlementTable(
- toggleItem(item.id, checked === true)
+ toggleItem(item, checked === true)
}
disabled={props.reviewPending}
aria-label={t(
@@ -234,7 +251,11 @@ export function BillingSettlementTable(
type='button'
variant='outline'
size='sm'
- onClick={() => props.onReviewItems([item])}
+ onClick={() =>
+ props.onReviewTargets([
+ { id: item.id, revision: item.revision },
+ ])
+ }
disabled={props.reviewPending}
>
{t('Review and close')}
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 e01158b9..4e0ddc38 100644
--- a/web/default/src/features/smart-ops/lib/format.test.ts
+++ b/web/default/src/features/smart-ops/lib/format.test.ts
@@ -149,7 +149,7 @@ describe('formatLocalizedCount', () => {
)
assert.equal(
zh.translation['Open manual settlements: {{count}}'],
- '未关闭的需人工处理结算:{{count}}'
+ '未关闭的需人工处理的结算:{{count}}'
)
assert.doesNotMatch(
zh.translation['Open pending settlements: {{count}}'],
diff --git a/web/default/src/features/smart-ops/types.ts b/web/default/src/features/smart-ops/types.ts
index 061e5a32..32ce59b9 100644
--- a/web/default/src/features/smart-ops/types.ts
+++ b/web/default/src/features/smart-ops/types.ts
@@ -90,11 +90,13 @@ export type BillingSettlementMutationResponse = {
data?: Record
}
+export type BillingSettlementReviewTarget = {
+ id: number
+ revision: number
+}
+
export type BillingSettlementReviewRequest = {
- items: Array<{
- id: number
- revision: number
- }>
+ items: BillingSettlementReviewTarget[]
}
export type BillingSettlementReconciliationResponse = {
diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json
index 484b8b53..ac7bb32f 100644
--- a/web/default/src/i18n/locales/zh.json
+++ b/web/default/src/i18n/locales/zh.json
@@ -5550,7 +5550,7 @@
"Open alert: {{count}}": "未关闭告警:{{count}}",
"Open alerts: {{count}}": "未关闭告警:{{count}}",
"Open billing reconciliation alerts": "未关闭的计费对账告警",
- "Open manual settlements: {{count}}": "未关闭的需人工处理结算:{{count}}",
+ "Open manual settlements: {{count}}": "未关闭的需人工处理的结算:{{count}}",
"Open pending settlements: {{count}}": "未关闭的待结算:{{count}}",
"Record policy: allow": "记录策略:允许",
"Record policy: block": "记录策略:阻止",
From c34324be3018e73f68def076156bc0879a751950 Mon Sep 17 00:00:00 2001
From: CSCITech
Date: Tue, 1 Sep 2026 01:39:09 +0800
Subject: [PATCH 7/8] chore: satisfy billing review checks
---
model/billing_settlement_regression_test.go | 4 ++--
.../smart-ops/components/billing-settlement-evidence.tsx | 6 ++++--
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/model/billing_settlement_regression_test.go b/model/billing_settlement_regression_test.go
index b913e4b2..e6abb931 100644
--- a/model/billing_settlement_regression_test.go
+++ b/model/billing_settlement_regression_test.go
@@ -328,7 +328,7 @@ func TestReviewBillingSettlementsClosesSelectionAtomically(t *testing.T) {
require.NoError(t, DB.Callback().Query().Before("gorm:query").Register(callbackName, func(_ *gorm.DB) {
queryCount++
}))
- t.Cleanup(func() { DB.Callback().Query().Remove(callbackName) })
+ t.Cleanup(func() { _ = DB.Callback().Query().Remove(callbackName) })
targets := []BillingSettlementReviewTarget{
{ID: records[1].ID, Revision: records[1].Revision},
@@ -411,7 +411,7 @@ func TestReviewBillingSettlementsRollsBackWhenBatchReadFails(t *testing.T) {
callbackName := "test:batch-review-read-failure"
readErr := errors.New("forced batch review read failure")
require.NoError(t, DB.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) {
- tx.AddError(readErr)
+ _ = tx.AddError(readErr)
}))
_, err := ReviewBillingSettlements([]BillingSettlementReviewTarget{{
ID: record.ID, Revision: record.Revision,
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 4606aa0d..d1f4a3de 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
@@ -111,7 +111,9 @@ export function BillingSettlementEvidence(
const reviewMutation = useMutation({
mutationKey: ['smart-ops', 'billing-settlement-reviews'],
- mutationFn: async (targets: BillingSettlementReviewTarget[]) => {
+ mutationFn: async (
+ targets: BillingSettlementReviewTarget[]
+ ): Promise => {
const response = await reviewBillingSettlements({ items: targets })
if (!response.success) {
throw new Error(
@@ -120,7 +122,7 @@ export function BillingSettlementEvidence(
}
return targets.length
},
- onSuccess: async (count) => {
+ onSuccess: async (count: number): Promise => {
setSelectedTargets(new Map())
toast.success(
t('Billing reconciliation alerts closed: {{count}}', { count })
From 7aa407577a94eb524420ca013c764ae568b77eb5 Mon Sep 17 00:00:00 2001
From: CSCITech
Date: Tue, 1 Sep 2026 02:39:47 +0800
Subject: [PATCH 8/8] fix: clarify partial billing review selection
---
web/default/src/components/ui/checkbox.tsx | 17 +++-
.../src/features/smart-ops/alerts.test.tsx | 8 ++
.../billing-settlement-evidence.tsx | 27 +++---
.../components/billing-settlement-table.tsx | 38 ++------
.../hooks/use-billing-settlement-selection.ts | 92 +++++++++++++++++++
5 files changed, 137 insertions(+), 45 deletions(-)
create mode 100644 web/default/src/features/smart-ops/hooks/use-billing-settlement-selection.ts
diff --git a/web/default/src/components/ui/checkbox.tsx b/web/default/src/components/ui/checkbox.tsx
index 08dc587b..77f269a3 100644
--- a/web/default/src/components/ui/checkbox.tsx
+++ b/web/default/src/components/ui/checkbox.tsx
@@ -19,7 +19,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API
'use client'
import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'
-import { Tick02Icon } from '@hugeicons/core-free-icons'
+import { MinusSignIcon, Tick02Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { cn } from '@/lib/utils'
@@ -28,7 +28,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
-
+
+
)
diff --git a/web/default/src/features/smart-ops/alerts.test.tsx b/web/default/src/features/smart-ops/alerts.test.tsx
index 20734868..89646d4f 100644
--- a/web/default/src/features/smart-ops/alerts.test.tsx
+++ b/web/default/src/features/smart-ops/alerts.test.tsx
@@ -503,9 +503,17 @@ describe('SmartOps active alerts', () => {
assert.ok(closeSelectedButton)
assert.equal(closeSelectedButton.hasAttribute('disabled'), true)
+ const firstRowSelection = within(view.container).getByRole('checkbox', {
+ name: 'Select billing reconciliation alert 91',
+ })
+ await view.click(firstRowSelection)
const selectAll = within(view.container).getByRole('checkbox', {
name: 'Select all billing reconciliation alerts',
})
+ assert.equal(selectAll.hasAttribute('data-indeterminate'), true)
+ assert.ok(
+ selectAll.querySelector('[data-checkbox-indicator="indeterminate"]')
+ )
await view.click(selectAll)
closeSelectedButton = within(view.container).getByRole('button', {
name: 'Review and close selected (2)',
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 d1f4a3de..2bbfefee 100644
--- a/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx
+++ b/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx
@@ -16,7 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
-import { useState, type ReactElement } from 'react'
+import { useMemo, useState, type ReactElement } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { CheckCircle2, Loader2, TriangleAlert } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -65,6 +65,21 @@ export function BillingSettlementEvidence(
const [selectedTargets, setSelectedTargets] = useState<
Map
>(() => new Map())
+ const reconciliationItems = props.data?.items
+ const { activeSelectedTargets, activeSelectedTargetMap } = useMemo(() => {
+ const currentRevisions = new Map(
+ reconciliationItems?.map((item) => [item.id, item.revision]) ?? []
+ )
+ const targets = Array.from(selectedTargets.values()).filter(
+ (target) => currentRevisions.get(target.id) === target.revision
+ )
+ return {
+ activeSelectedTargets: targets,
+ activeSelectedTargetMap: new Map(
+ targets.map((target) => [target.id, target])
+ ),
+ }
+ }, [reconciliationItems, selectedTargets])
const [policyOverride, setPolicyOverride] = useState(null)
const queryClient = useQueryClient()
const policyValue =
@@ -196,16 +211,6 @@ export function BillingSettlementEvidence(
return <>>
}
- const currentRevisions = new Map(
- props.data.items.map((item) => [item.id, item.revision])
- )
- const activeSelectedTargets = Array.from(selectedTargets.values()).filter(
- (target) => currentRevisions.get(target.id) === target.revision
- )
- const activeSelectedTargetMap = new Map(
- activeSelectedTargets.map((target) => [target.id, target])
- )
-
return (
- props.selectedTargets.get(item.id)?.revision === item.revision
- const allSelected =
- props.items.length > 0 && props.items.every((item) => isSelected(item))
- const someSelected = props.items.some((item) => isSelected(item))
-
- const toggleAll = (checked: boolean): void => {
- props.onSelectedTargetsChange(
- checked
- ? new Map(
- props.items.map((item) => [
- item.id,
- { id: item.id, revision: item.revision },
- ])
- )
- : new Map()
- )
- }
-
- const toggleItem = (
- item: BillingSettlementReconciliationItem,
- checked: boolean
- ): void => {
- const next = new Map(props.selectedTargets)
- if (checked) {
- next.set(item.id, { id: item.id, revision: item.revision })
- } else {
- next.delete(item.id)
- }
- props.onSelectedTargetsChange(next)
- }
+ const { allSelected, someSelected, isSelected, toggleAll, toggleItem } =
+ useBillingSettlementSelection({
+ items: props.items,
+ selectedTargets: props.selectedTargets,
+ onSelectedTargetsChange: props.onSelectedTargetsChange,
+ })
return (
diff --git a/web/default/src/features/smart-ops/hooks/use-billing-settlement-selection.ts b/web/default/src/features/smart-ops/hooks/use-billing-settlement-selection.ts
new file mode 100644
index 00000000..5d145859
--- /dev/null
+++ b/web/default/src/features/smart-ops/hooks/use-billing-settlement-selection.ts
@@ -0,0 +1,92 @@
+/*
+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 { useCallback, useMemo } from 'react'
+import type {
+ BillingSettlementReconciliationItem,
+ BillingSettlementReviewTarget,
+} from '../types'
+
+interface UseBillingSettlementSelectionParams {
+ items: BillingSettlementReconciliationItem[]
+ selectedTargets: ReadonlyMap
+ onSelectedTargetsChange: (
+ targets: Map
+ ) => void
+}
+
+interface UseBillingSettlementSelectionResult {
+ allSelected: boolean
+ someSelected: boolean
+ isSelected: (item: BillingSettlementReconciliationItem) => boolean
+ toggleAll: (checked: boolean) => void
+ toggleItem: (
+ item: BillingSettlementReconciliationItem,
+ checked: boolean
+ ) => void
+}
+
+export function useBillingSettlementSelection(
+ params: UseBillingSettlementSelectionParams
+): UseBillingSettlementSelectionResult {
+ const items = params.items
+ const selectedTargets = params.selectedTargets
+ const onSelectedTargetsChange = params.onSelectedTargetsChange
+ const isSelected = useCallback(
+ (item: BillingSettlementReconciliationItem): boolean =>
+ selectedTargets.get(item.id)?.revision === item.revision,
+ [selectedTargets]
+ )
+ const { allSelected, someSelected } = useMemo(() => {
+ return {
+ allSelected: items.length > 0 && items.every((item) => isSelected(item)),
+ someSelected: items.some((item) => isSelected(item)),
+ }
+ }, [isSelected, items])
+
+ const toggleAll = useCallback(
+ (checked: boolean): void => {
+ onSelectedTargetsChange(
+ checked
+ ? new Map(
+ items.map((item) => [
+ item.id,
+ { id: item.id, revision: item.revision },
+ ])
+ )
+ : new Map()
+ )
+ },
+ [items, onSelectedTargetsChange]
+ )
+
+ const toggleItem = useCallback(
+ (item: BillingSettlementReconciliationItem, checked: boolean): void => {
+ const next = new Map(selectedTargets)
+ if (checked) {
+ next.set(item.id, { id: item.id, revision: item.revision })
+ } else {
+ next.delete(item.id)
+ }
+ onSelectedTargetsChange(next)
+ },
+ [onSelectedTargetsChange, selectedTargets]
+ )
+
+ return { allSelected, someSelected, isSelected, toggleAll, toggleItem }
+}