dsh-whale-widget 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ // Balance observations are not a transaction API. Keep them separate from
2
+ // token estimates and require explicit credits/debits for reconciliation.
3
+ export const ACCOUNTING_VERSION = 1
4
+ const SCALE = 100000000
5
+
6
+ export function moneyUnits(value) {
7
+ const n = Number(value)
8
+ const units = Math.round(n * SCALE)
9
+ if (!Number.isFinite(n) || !Number.isSafeInteger(units)) throw new Error('金额无效或超出可记账范围')
10
+ return units
11
+ }
12
+
13
+ export function preciseMoney(value) { return moneyUnits(value) / SCALE }
14
+ export function addMoney(a, b) { return sumMoney([a, b]) }
15
+ export function sumMoney(values) {
16
+ let units = 0
17
+ for (const value of values) units += moneyUnits(value)
18
+ if (!Number.isSafeInteger(units)) throw new Error('金额合计超出可记账范围')
19
+ return units / SCALE
20
+ }
21
+
22
+ export function beijingDay(time = Date.now()) {
23
+ const d = new Date(Number(time) + 8 * 3600000)
24
+ if (!Number.isFinite(d.getTime())) throw new Error('无效的观测时间')
25
+ return d.toISOString().slice(0, 10)
26
+ }
27
+
28
+ export function dayOffset(day, offset) {
29
+ return beijingDay(Date.parse(day + 'T00:00:00+08:00') + offset * 86400000)
30
+ }
31
+
32
+ function currentBook(ledger) {
33
+ const a = ledger.accounting
34
+ return a && a.version === ACCOUNTING_VERSION && a.books && a.books[a.active]
35
+ }
36
+
37
+ export function accountingDays(ledger) { return Object.keys(currentBook(ledger)?.days || {}) }
38
+
39
+ function observedAmount(day) {
40
+ const c = day.correction
41
+ return (c ? c.amountUnits + day.debitUnits - c.debitUnits : day.debitUnits) / SCALE
42
+ }
43
+
44
+ function revisionOf(ledger, day) {
45
+ return [ledger.accounting.active, day.day, day.firstAt, day.lastUnits,
46
+ day.debitUnits, day.creditUnits, day.revision || 0].join(':')
47
+ }
48
+
49
+ export function balanceSummary(ledger, day = beijingDay()) {
50
+ const book = currentBook(ledger)
51
+ const row = book && book.days && book.days[day]
52
+ if (!row) return null
53
+ const correction = row.correction
54
+ const needsReview = row.creditUnits > (correction ? correction.creditUnits : 0)
55
+ const source = needsReview ? 'balance-needs-review' : correction ? 'balance-corrected' : 'balance-observed'
56
+ return {
57
+ day, amount: preciseMoney(observedAmount(row)), currency: book.currency, source,
58
+ label: needsReview ? '已观测消费 · 待核对余额调整' : correction ? '已校正消费' : '已观测消费',
59
+ firstObservedAt: row.firstAt, lastObservedAt: row.lastAt,
60
+ openingBalance: row.openingUnits / SCALE, currentBalance: row.lastUnits / SCALE,
61
+ observedDecrease: row.debitUnits / SCALE, observedIncrease: row.creditUnits / SCALE,
62
+ needsReview, partialDay: true, revision: revisionOf(ledger, row),
63
+ credits: correction ? correction.creditsUnits / SCALE : null,
64
+ otherDebits: correction ? correction.otherDebitsUnits / SCALE : null,
65
+ correctedAt: correction ? correction.at : null,
66
+ }
67
+ }
68
+
69
+ // Mutates the caller-owned ledger; this module itself never reads or writes files.
70
+ export function observeBalance(ledger, snapshot) {
71
+ const at = Number(snapshot.at ?? Date.now())
72
+ const day = beijingDay(at)
73
+ const units = moneyUnits(snapshot.balance)
74
+ const currency = String(snapshot.currency || 'CNY').toUpperCase()
75
+ if (!/^[A-Z]{3}$/.test(currency)) throw new Error('余额币种无效')
76
+ const scope = String(snapshot.scope || 'default')
77
+ if (!/^[a-zA-Z0-9_-]{1,80}$/.test(scope)) throw new Error('账户标识无效')
78
+ const context = scope + '-' + currency
79
+ let a = ledger.accounting
80
+ if (!a || a.version !== ACCOUNTING_VERSION) {
81
+ // Legacy totals have no trustworthy recharge metadata. Preserve them for
82
+ // reference; begin a new explicitly timed observation window.
83
+ a = ledger.accounting = {
84
+ version: ACCOUNTING_VERSION, active: context, books: {}, migratedAt: at,
85
+ legacyHistory: { ...(ledger.history || {}) },
86
+ }
87
+ }
88
+ a.books ||= {}
89
+ let book = a.books[context]
90
+ if (!book) book = a.books[context] = { currency, days: {} }
91
+ // Ignore duplicate/out-of-order samples, including a late sample from yesterday.
92
+ if (book.lastAt != null && at <= book.lastAt) return balanceSummary(ledger, ledger.date)
93
+ a.active = context
94
+ let row = book.days[day]
95
+ if (!row) {
96
+ row = book.days[day] = {
97
+ day, firstAt: at, lastAt: at, openingUnits: units, lastUnits: units,
98
+ debitUnits: 0, creditUnits: 0, revision: 0, correction: null,
99
+ }
100
+ } else {
101
+ const delta = row.lastUnits - units
102
+ if (delta > 0) row.debitUnits += delta
103
+ if (delta < 0) row.creditUnits -= delta
104
+ row.lastUnits = units
105
+ row.lastAt = at
106
+ }
107
+ book.lastAt = at
108
+ ledger.date = day
109
+ // Compatibility fields for the existing UI and old settings writers.
110
+ ledger.dayStart = row.openingUnits / SCALE
111
+ ledger.lastBalance = row.lastUnits / SCALE
112
+ const summary = balanceSummary(ledger, day)
113
+ ledger.todayUsage = summary.amount
114
+ ledger.history ||= {}
115
+ ledger.history[day] = summary.amount
116
+ return summary
117
+ }
118
+
119
+ function adjustmentUnits(value, required = false) {
120
+ if (value === '' || value === null || value === undefined) {
121
+ if (required) throw new Error('请填写本统计区间的累计到账金额,未充值请填 0')
122
+ return 0
123
+ }
124
+ if (!/^(?:0|[1-9]\d*)(?:\.\d{1,8})?$/.test(String(value))) {
125
+ throw new Error('金额须为非负数,最多保留 8 位小数')
126
+ }
127
+ const units = moneyUnits(value)
128
+ if (units < 0) throw new Error('金额不能为负数')
129
+ return units
130
+ }
131
+
132
+ export function reconcileBalance(ledger, input, now = Date.now()) {
133
+ const day = String(input.day || '')
134
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) throw new Error('请选择有效的记账日期')
135
+ const book = currentBook(ledger)
136
+ const row = book && book.days && book.days[day]
137
+ if (!row) throw new Error('这一天没有余额观测记录,无法校正')
138
+ if (input.revision !== revisionOf(ledger, row)) {
139
+ const err = new Error('余额或校正记录已更新,请重新打开校正窗口后核对金额')
140
+ err.status = 409
141
+ throw err
142
+ }
143
+ if (input.action !== 'reset' && input.confirmed !== true) throw new Error('请先确认已核对本统计区间的全部余额调整')
144
+ let correction = null
145
+ if (input.action !== 'reset') {
146
+ const creditsUnits = adjustmentUnits(input.credits, true)
147
+ const otherDebitsUnits = adjustmentUnits(input.otherDebits)
148
+ const amountUnits = row.openingUnits + creditsUnits - otherDebitsUnits - row.lastUnits
149
+ if (!Number.isSafeInteger(amountUnits) || amountUnits < 0) {
150
+ throw new Error('校正后消费为负或超出范围,请核对统计起点与累计到账金额')
151
+ }
152
+ correction = {
153
+ at: Number(now), creditsUnits, otherDebitsUnits, amountUnits,
154
+ debitUnits: row.debitUnits, creditUnits: row.creditUnits,
155
+ }
156
+ }
157
+ row.correctionLog ||= []
158
+ row.correctionLog.push({ at: Number(now), previous: row.correction, next: correction })
159
+ if (row.correctionLog.length > 50) row.correctionLog.splice(0, row.correctionLog.length - 50)
160
+ row.correction = correction
161
+ row.revision = (row.revision || 0) + 1
162
+ const summary = balanceSummary(ledger, day)
163
+ ledger.history ||= {}
164
+ ledger.history[day] = summary.amount
165
+ if (ledger.date === day) ledger.todayUsage = summary.amount
166
+ return summary
167
+ }
168
+
169
+ export function eventEstimate(ledger, day) {
170
+ return sumMoney((Array.isArray(ledger.events) ? ledger.events : [])
171
+ .filter(e => e.day === day).map(e => Number(e.cost) || 0))
172
+ }
173
+
174
+ export function daySummary(ledger, day) {
175
+ const observed = balanceSummary(ledger, day)
176
+ const estimate = eventEstimate(ledger, day)
177
+ if (observed) return { ...observed, eventEstimate: estimate, eventCurrency: 'CNY' }
178
+ // An explicit historical total (even zero) wins over a conflicting estimate.
179
+ const history = ledger.accounting ? ledger.accounting.legacyHistory : ledger.history
180
+ const h = history && history[day]
181
+ const hasHistory = typeof h === 'number' && Number.isFinite(h)
182
+ return {
183
+ day, amount: hasHistory ? preciseMoney(h) : estimate, currency: 'CNY',
184
+ source: hasHistory ? 'legacy' : 'events', label: hasHistory ? '旧版记录 · 未校正' : '本地估算',
185
+ eventEstimate: estimate, eventCurrency: 'CNY', partialDay: true,
186
+ }
187
+ }