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.
- package/PROVENANCE.md +35 -0
- package/README.md +29 -11
- package/assets/DSH2.png +0 -0
- package/assets/whale-widget.js +753 -133
- package/lib/accounting.mjs +187 -0
- package/lib/index.js +248 -207
- package/package.json +3 -2
|
@@ -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
|
+
}
|