@zhin.js/plugin-ledger 0.0.0-stage → 1.0.1

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.
Files changed (72) hide show
  1. package/README.md +83 -2
  2. package/commands/ledger-add/index.js +17 -0
  3. package/commands/ledger-add/index.ts +17 -0
  4. package/commands/ledger-delete/index.js +17 -0
  5. package/commands/ledger-delete/index.ts +17 -0
  6. package/commands/ledger-list/index.js +21 -0
  7. package/commands/ledger-list/index.ts +20 -0
  8. package/commands/ledger-month/index.js +14 -0
  9. package/commands/ledger-month/index.ts +14 -0
  10. package/commands/ledger-report/index.js +23 -0
  11. package/commands/ledger-report/index.ts +25 -0
  12. package/commands/ledger-stats/index.js +21 -0
  13. package/commands/ledger-stats/index.ts +20 -0
  14. package/commands/ledger-today/index.js +14 -0
  15. package/commands/ledger-today/index.ts +14 -0
  16. package/commands/ledger-week/index.js +14 -0
  17. package/commands/ledger-week/index.ts +14 -0
  18. package/lib/config.d.ts +21 -0
  19. package/lib/config.d.ts.map +1 -0
  20. package/lib/config.js +30 -0
  21. package/lib/config.js.map +1 -0
  22. package/lib/entry-input.d.ts +18 -0
  23. package/lib/entry-input.d.ts.map +1 -0
  24. package/lib/entry-input.js +31 -0
  25. package/lib/entry-input.js.map +1 -0
  26. package/lib/identity.d.ts +22 -0
  27. package/lib/identity.d.ts.map +1 -0
  28. package/lib/identity.js +37 -0
  29. package/lib/identity.js.map +1 -0
  30. package/lib/index.d.ts +10 -0
  31. package/lib/index.d.ts.map +1 -0
  32. package/lib/index.js +10 -0
  33. package/lib/index.js.map +1 -0
  34. package/lib/money.d.ts +20 -0
  35. package/lib/money.d.ts.map +1 -0
  36. package/lib/money.js +48 -0
  37. package/lib/money.js.map +1 -0
  38. package/lib/period.d.ts +21 -0
  39. package/lib/period.d.ts.map +1 -0
  40. package/lib/period.js +98 -0
  41. package/lib/period.js.map +1 -0
  42. package/lib/report-format.d.ts +30 -0
  43. package/lib/report-format.d.ts.map +1 -0
  44. package/lib/report-format.js +98 -0
  45. package/lib/report-format.js.map +1 -0
  46. package/lib/runtime.d.ts +3 -0
  47. package/lib/runtime.d.ts.map +1 -0
  48. package/lib/runtime.js +3 -0
  49. package/lib/runtime.js.map +1 -0
  50. package/lib/service.d.ts +33 -0
  51. package/lib/service.d.ts.map +1 -0
  52. package/lib/service.js +206 -0
  53. package/lib/service.js.map +1 -0
  54. package/lib/store.d.ts +48 -0
  55. package/lib/store.d.ts.map +1 -0
  56. package/lib/store.js +162 -0
  57. package/lib/store.js.map +1 -0
  58. package/package.json +90 -3
  59. package/plugin.js +19 -0
  60. package/schedules/daily-report/index.js +10 -0
  61. package/schedules/daily-report/index.ts +10 -0
  62. package/schema.json +31 -0
  63. package/src/config.ts +39 -0
  64. package/src/entry-input.ts +39 -0
  65. package/src/identity.ts +50 -0
  66. package/src/index.ts +29 -0
  67. package/src/money.ts +53 -0
  68. package/src/period.ts +101 -0
  69. package/src/report-format.ts +125 -0
  70. package/src/runtime.ts +4 -0
  71. package/src/service.ts +217 -0
  72. package/src/store.ts +195 -0
package/lib/store.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { DeliveryTarget, PluginDatabaseHost } from 'zhin.js';
2
+ export declare const ENTRY_TABLE = "ledger_entries";
3
+ export declare const USER_TABLE = "ledger_users";
4
+ /** Separate table: existing databases only run CREATE TABLE IF NOT EXISTS, so new columns on `ledger_users` would never appear. */
5
+ export declare const PRIVATE_TARGET_TABLE = "ledger_private_targets";
6
+ /** One expense. `seq` is the per-user number shown as `#id`; amounts are integer cents. */
7
+ export interface LedgerEntry {
8
+ readonly ownerKey: string;
9
+ readonly seq: number;
10
+ readonly category: string;
11
+ readonly amountCents: number;
12
+ readonly note: string;
13
+ readonly createdAt: number;
14
+ }
15
+ /** Per-user settings: the next entry number and the opt-in daily report. */
16
+ export interface LedgerUserRecord {
17
+ readonly ownerKey: string;
18
+ readonly nextSeq: number;
19
+ readonly reportEnabled: boolean;
20
+ readonly reportTarget: DeliveryTarget | undefined;
21
+ readonly lastReportDate: string;
22
+ }
23
+ /** Registers both tables; called from `setup()` before the database starts. */
24
+ export declare function defineLedgerTables(database: Pick<PluginDatabaseHost, 'define'>): void;
25
+ /**
26
+ * Every query filters on `owner_key`, the caller's own identity, so one user can never read,
27
+ * number or delete another user's entries. Models are looked up per call because the
28
+ * database only starts after `setup()`.
29
+ */
30
+ export declare class LedgerStore {
31
+ #private;
32
+ constructor(database: PluginDatabaseHost);
33
+ getUser(ownerKey: string): Promise<LedgerUserRecord | undefined>;
34
+ /** Reserves the next per-user entry number. Callers serialise per user (see LedgerService). */
35
+ reserveSequence(ownerKey: string): Promise<number>;
36
+ insertEntry(entry: LedgerEntry): Promise<void>;
37
+ listEntries(ownerKey: string): Promise<LedgerEntry[]>;
38
+ findEntry(ownerKey: string, seq: number): Promise<LedgerEntry | undefined>;
39
+ deleteEntry(ownerKey: string, seq: number): Promise<void>;
40
+ setReport(ownerKey: string, enabled: boolean, target: DeliveryTarget | undefined, lastReportDate: string): Promise<void>;
41
+ /** The real private conversation saved when the user talked to the bot in private. */
42
+ getPrivateTarget(ownerKey: string): Promise<DeliveryTarget | undefined>;
43
+ /** Saves the real private conversation; an enabled daily report follows it. */
44
+ setPrivateTarget(ownerKey: string, target: DeliveryTarget): Promise<void>;
45
+ listReportSubscribers(): Promise<LedgerUserRecord[]>;
46
+ markReported(ownerKey: string, dateKey: string): Promise<void>;
47
+ }
48
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAqB,cAAc,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAErF,eAAO,MAAM,WAAW,mBAAmB,CAAC;AAC5C,eAAO,MAAM,UAAU,iBAAiB,CAAC;AACzC,mIAAmI;AACnI,eAAO,MAAM,oBAAoB,2BAA2B,CAAC;AAE7D,2FAA2F;AAC3F,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,4EAA4E;AAC5E,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC,QAAQ,CAAC,YAAY,EAAE,cAAc,GAAG,SAAS,CAAC;IAClD,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CACjC;AAED,+EAA+E;AAC/E,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,IAAI,CAAC,kBAAkB,EAAE,QAAQ,CAAC,GAAG,IAAI,CAoBrF;AAgCD;;;;GAIG;AACH,qBAAa,WAAW;;gBAGV,QAAQ,EAAE,kBAAkB;IAUlC,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC;IAqBtE,+FAA+F;IACzF,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAOlD,WAAW,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAW9C,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IAOrD,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC;IAQ1E,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIzD,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,cAAc,GAAG,SAAS,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAS9H,sFAAsF;IAChF,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC;IAK7E,+EAA+E;IACzE,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;IAYzE,qBAAqB,IAAI,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAOpD,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAGrE"}
package/lib/store.js ADDED
@@ -0,0 +1,162 @@
1
+ export const ENTRY_TABLE = 'ledger_entries';
2
+ export const USER_TABLE = 'ledger_users';
3
+ /** Separate table: existing databases only run CREATE TABLE IF NOT EXISTS, so new columns on `ledger_users` would never appear. */
4
+ export const PRIVATE_TARGET_TABLE = 'ledger_private_targets';
5
+ /** Registers both tables; called from `setup()` before the database starts. */
6
+ export function defineLedgerTables(database) {
7
+ database.define(ENTRY_TABLE, {
8
+ owner_key: { type: 'text', nullable: false },
9
+ seq: { type: 'integer', nullable: false },
10
+ category: { type: 'text', nullable: false },
11
+ amount_cents: { type: 'integer', nullable: false },
12
+ note: { type: 'text', default: '' },
13
+ created_at: { type: 'text', nullable: false },
14
+ });
15
+ database.define(USER_TABLE, {
16
+ owner_key: { type: 'text', nullable: false },
17
+ next_seq: { type: 'integer', default: 1 },
18
+ report_enabled: { type: 'integer', default: 0 },
19
+ report_target: { type: 'text', default: '' },
20
+ last_report_date: { type: 'text', default: '' },
21
+ });
22
+ database.define(PRIVATE_TARGET_TABLE, {
23
+ owner_key: { type: 'text', nullable: false },
24
+ target: { type: 'text', nullable: false },
25
+ });
26
+ }
27
+ function toEntry(row) {
28
+ return {
29
+ ownerKey: String(row.owner_key),
30
+ seq: Number(row.seq),
31
+ category: String(row.category),
32
+ amountCents: Number(row.amount_cents),
33
+ note: String(row.note ?? ''),
34
+ createdAt: Date.parse(String(row.created_at)),
35
+ };
36
+ }
37
+ function parseTarget(raw) {
38
+ if (typeof raw !== 'string' || raw === '')
39
+ return undefined;
40
+ try {
41
+ return JSON.parse(raw);
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
47
+ function toUser(row) {
48
+ return {
49
+ ownerKey: String(row.owner_key),
50
+ nextSeq: Number(row.next_seq ?? 1),
51
+ reportEnabled: Number(row.report_enabled ?? 0) === 1,
52
+ reportTarget: parseTarget(row.report_target),
53
+ lastReportDate: String(row.last_report_date ?? ''),
54
+ };
55
+ }
56
+ /**
57
+ * Every query filters on `owner_key`, the caller's own identity, so one user can never read,
58
+ * number or delete another user's entries. Models are looked up per call because the
59
+ * database only starts after `setup()`.
60
+ */
61
+ export class LedgerStore {
62
+ #database;
63
+ constructor(database) {
64
+ this.#database = database;
65
+ }
66
+ #model(table) {
67
+ const model = this.#database.models.get(table);
68
+ if (!model)
69
+ throw new Error(`记账数据表 ${table} 不可用`);
70
+ return model;
71
+ }
72
+ async getUser(ownerKey) {
73
+ const rows = await this.#model(USER_TABLE)
74
+ .select('owner_key', 'next_seq', 'report_enabled', 'report_target', 'last_report_date')
75
+ .where({ owner_key: ownerKey });
76
+ const first = rows[0];
77
+ return first ? toUser(first) : undefined;
78
+ }
79
+ async #ensureUser(ownerKey) {
80
+ const existing = await this.getUser(ownerKey);
81
+ if (existing)
82
+ return existing;
83
+ await this.#model(USER_TABLE).insert({
84
+ owner_key: ownerKey,
85
+ next_seq: 1,
86
+ report_enabled: 0,
87
+ report_target: '',
88
+ last_report_date: '',
89
+ });
90
+ return { ownerKey, nextSeq: 1, reportEnabled: false, reportTarget: undefined, lastReportDate: '' };
91
+ }
92
+ /** Reserves the next per-user entry number. Callers serialise per user (see LedgerService). */
93
+ async reserveSequence(ownerKey) {
94
+ const user = await this.#ensureUser(ownerKey);
95
+ const reserved = user.nextSeq;
96
+ await this.#model(USER_TABLE).update({ next_seq: reserved + 1 }).where({ owner_key: ownerKey });
97
+ return reserved;
98
+ }
99
+ async insertEntry(entry) {
100
+ await this.#model(ENTRY_TABLE).insert({
101
+ owner_key: entry.ownerKey,
102
+ seq: entry.seq,
103
+ category: entry.category,
104
+ amount_cents: entry.amountCents,
105
+ note: entry.note,
106
+ created_at: new Date(entry.createdAt).toISOString(),
107
+ });
108
+ }
109
+ async listEntries(ownerKey) {
110
+ const rows = await this.#model(ENTRY_TABLE)
111
+ .select('owner_key', 'seq', 'category', 'amount_cents', 'note', 'created_at')
112
+ .where({ owner_key: ownerKey });
113
+ return rows.map(toEntry);
114
+ }
115
+ async findEntry(ownerKey, seq) {
116
+ const rows = await this.#model(ENTRY_TABLE)
117
+ .select('owner_key', 'seq', 'category', 'amount_cents', 'note', 'created_at')
118
+ .where({ owner_key: ownerKey, seq });
119
+ const first = rows[0];
120
+ return first ? toEntry(first) : undefined;
121
+ }
122
+ async deleteEntry(ownerKey, seq) {
123
+ await this.#model(ENTRY_TABLE).delete().where({ owner_key: ownerKey, seq });
124
+ }
125
+ async setReport(ownerKey, enabled, target, lastReportDate) {
126
+ await this.#ensureUser(ownerKey);
127
+ await this.#model(USER_TABLE).update({
128
+ report_enabled: enabled ? 1 : 0,
129
+ report_target: target ? JSON.stringify(target) : '',
130
+ last_report_date: lastReportDate,
131
+ }).where({ owner_key: ownerKey });
132
+ }
133
+ /** The real private conversation saved when the user talked to the bot in private. */
134
+ async getPrivateTarget(ownerKey) {
135
+ const rows = await this.#model(PRIVATE_TARGET_TABLE).select('owner_key', 'target').where({ owner_key: ownerKey });
136
+ return parseTarget(rows[0]?.target);
137
+ }
138
+ /** Saves the real private conversation; an enabled daily report follows it. */
139
+ async setPrivateTarget(ownerKey, target) {
140
+ const raw = JSON.stringify(target);
141
+ const model = this.#model(PRIVATE_TARGET_TABLE);
142
+ const existing = await model.select('owner_key', 'target').where({ owner_key: ownerKey });
143
+ if (existing.length > 0)
144
+ await model.update({ target: raw }).where({ owner_key: ownerKey });
145
+ else
146
+ await model.insert({ owner_key: ownerKey, target: raw });
147
+ const user = await this.getUser(ownerKey);
148
+ if (user?.reportEnabled) {
149
+ await this.#model(USER_TABLE).update({ report_target: raw }).where({ owner_key: ownerKey });
150
+ }
151
+ }
152
+ async listReportSubscribers() {
153
+ const rows = await this.#model(USER_TABLE)
154
+ .select('owner_key', 'next_seq', 'report_enabled', 'report_target', 'last_report_date')
155
+ .where({ report_enabled: 1 });
156
+ return rows.map(toUser);
157
+ }
158
+ async markReported(ownerKey, dateKey) {
159
+ await this.#model(USER_TABLE).update({ last_report_date: dateKey }).where({ owner_key: ownerKey });
160
+ }
161
+ }
162
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,WAAW,GAAG,gBAAgB,CAAC;AAC5C,MAAM,CAAC,MAAM,UAAU,GAAG,cAAc,CAAC;AACzC,mIAAmI;AACnI,MAAM,CAAC,MAAM,oBAAoB,GAAG,wBAAwB,CAAC;AAqB7D,+EAA+E;AAC/E,MAAM,UAAU,kBAAkB,CAAC,QAA4C;IAC7E,QAAQ,CAAC,MAAM,CAAC,WAAW,EAAE;QAC3B,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE;QAC5C,GAAG,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,KAAK,EAAE;QACzC,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE;QAC3C,YAAY,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,KAAK,EAAE;QAClD,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;QACnC,UAAU,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE;KAC9C,CAAC,CAAC;IACH,QAAQ,CAAC,MAAM,CAAC,UAAU,EAAE;QAC1B,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE;QAC5C,QAAQ,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,EAAE;QACzC,cAAc,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,EAAE;QAC/C,aAAa,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;QAC5C,gBAAgB,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;KAChD,CAAC,CAAC;IACH,QAAQ,CAAC,MAAM,CAAC,oBAAoB,EAAE;QACpC,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE;QAC5C,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE;KAC1C,CAAC,CAAC;AACL,CAAC;AAED,SAAS,OAAO,CAAC,GAA4B;IAC3C,OAAO;QACL,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC;QAC/B,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC;QACpB,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QAC9B,WAAW,EAAE,MAAM,CAAC,GAAG,CAAC,YAAY,CAAC;QACrC,IAAI,EAAE,MAAM,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC;QAC5B,SAAS,EAAE,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;KAC9C,CAAC;AACJ,CAAC;AAED,SAAS,WAAW,CAAC,GAAY;IAC/B,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,EAAE;QAAE,OAAO,SAAS,CAAC;IAC5D,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAmB,CAAC;IAC3C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,SAAS,MAAM,CAAC,GAA4B;IAC1C,OAAO;QACL,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC;QAC/B,OAAO,EAAE,MAAM,CAAC,GAAG,CAAC,QAAQ,IAAI,CAAC,CAAC;QAClC,aAAa,EAAE,MAAM,CAAC,GAAG,CAAC,cAAc,IAAI,CAAC,CAAC,KAAK,CAAC;QACpD,YAAY,EAAE,WAAW,CAAC,GAAG,CAAC,aAAa,CAAC;QAC5C,cAAc,EAAE,MAAM,CAAC,GAAG,CAAC,gBAAgB,IAAI,EAAE,CAAC;KACnD,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,OAAO,WAAW;IACb,SAAS,CAAqB;IAEvC,YAAY,QAA4B;QACtC,IAAI,CAAC,SAAS,GAAG,QAAQ,CAAC;IAC5B,CAAC;IAED,MAAM,CAAC,KAAa;QAClB,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAC/C,IAAI,CAAC,KAAK;YAAE,MAAM,IAAI,KAAK,CAAC,SAAS,KAAK,MAAM,CAAC,CAAC;QAClD,OAAO,KAAK,CAAC;IACf,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,QAAgB;QAC5B,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC;aACvC,MAAM,CAAC,WAAW,EAAE,UAAU,EAAE,gBAAgB,EAAE,eAAe,EAAE,kBAAkB,CAAC;aACtF,KAAK,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC;QAClC,MAAM,KAAK,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QACtB,OAAO,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAC3C,CAAC;IAED,KAAK,CAAC,WAAW,CAAC,QAAgB;QAChC,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;QAC9C,IAAI,QAAQ;YAAE,OAAO,QAAQ,CAAC;QAC9B,MAAM,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC;YACnC,SAAS,EAAE,QAAQ;YACnB,QAAQ,EAAE,CAAC;YACX,cAAc,EAAE,CAAC;YACjB,aAAa,EAAE,EAAE;YACjB,gBAAgB,EAAE,EAAE;SACrB,CAAC,CAAC;QACH,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,EAAE,aAAa,EAAE,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,cAAc,EAAE,EAAE,EAAE,CAAC;IACrG,CAAC;IAED,+FAA+F;IAC/F,KAAK,CAAC,eAAe,CAAC,QAAgB;QACpC,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC;QAC9C,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC;QAC9B,MAAM,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,QAAQ,GAAG,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC;QAChG,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,KAAK,CAAC,WAAW,CAAC,KAAkB;QAClC,MAAM,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC;YACpC,SAAS,EAAE,KAAK,CAAC,QAAQ;YACzB,GAAG,EAAE,KAAK,CAAC,GAAG;YACd,QAAQ,EAAE,KAAK,CAAC,QAAQ;YACxB,YAAY,EAAE,KAAK,CAAC,WAAW;YAC/B,IAAI,EAAE,KAAK,CAAC,IAAI;YAChB,UAAU,EAAE,IAAI,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,WAAW,EAAE;SACpD,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,WAAW,CAAC,QAAgB;QAChC,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC;aACxC,MAAM,CAAC,WAAW,EAAE,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,EAAE,YAAY,CAAC;aAC5E,KAAK,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC;QAClC,OAAO,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAC3B,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,QAAgB,EAAE,GAAW;QAC3C,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC;aACxC,MAAM,CAAC,WAAW,EAAE,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,EAAE,YAAY,CAAC;aAC5E,KAAK,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAC,CAAC;QACvC,MAAM,KAAK,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QACtB,OAAO,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAC5C,CAAC;IAED,KAAK,CAAC,WAAW,CAAC,QAAgB,EAAE,GAAW;QAC7C,MAAM,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAC,CAAC;IAC9E,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,QAAgB,EAAE,OAAgB,EAAE,MAAkC,EAAE,cAAsB;QAC5G,MAAM,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC;QACjC,MAAM,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC;YACnC,cAAc,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YAC/B,aAAa,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE;YACnD,gBAAgB,EAAE,cAAc;SACjC,CAAC,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC;IACpC,CAAC;IAED,sFAAsF;IACtF,KAAK,CAAC,gBAAgB,CAAC,QAAgB;QACrC,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,oBAAoB,CAAC,CAAC,MAAM,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC;QAClH,OAAO,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;IACtC,CAAC;IAED,+EAA+E;IAC/E,KAAK,CAAC,gBAAgB,CAAC,QAAgB,EAAE,MAAsB;QAC7D,MAAM,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;QACnC,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,oBAAoB,CAAC,CAAC;QAChD,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,MAAM,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC;QAC1F,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC;YAAE,MAAM,KAAK,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC;;YACvF,MAAM,KAAK,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;QAC9D,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;QAC1C,IAAI,IAAI,EAAE,aAAa,EAAE,CAAC;YACxB,MAAM,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,EAAE,aAAa,EAAE,GAAG,EAAE,CAAC,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC;QAC9F,CAAC;IACH,CAAC;IAED,KAAK,CAAC,qBAAqB;QACzB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC;aACvC,MAAM,CAAC,WAAW,EAAE,UAAU,EAAE,gBAAgB,EAAE,eAAe,EAAE,kBAAkB,CAAC;aACtF,KAAK,CAAC,EAAE,cAAc,EAAE,CAAC,EAAE,CAAC,CAAC;QAChC,OAAO,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAC1B,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,QAAgB,EAAE,OAAe;QAClD,MAAM,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,EAAE,gBAAgB,EAAE,OAAO,EAAE,CAAC,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC;IACrG,CAAC;CACF"}
package/package.json CHANGED
@@ -1,6 +1,93 @@
1
1
  {
2
2
  "name": "@zhin.js/plugin-ledger",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.1",
4
+ "description": "Personal expense ledger for Zhin.js (Plugin Runtime): per-user entries in integer cents, period and category summaries, group privacy and an opt-in daily report",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "import": "./lib/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "README.md",
17
+ "commands",
18
+ "lib",
19
+ "plugin.js",
20
+ "schedules",
21
+ "src",
22
+ "schema.json"
23
+ ],
24
+ "keywords": [
25
+ "zhin",
26
+ "zhin.js",
27
+ "bot",
28
+ "plugin",
29
+ "ledger",
30
+ "expense"
31
+ ],
32
+ "author": {
33
+ "name": "lc-cn",
34
+ "email": "admin@liucl.cn",
35
+ "url": "https://github.com/lc-cn"
36
+ },
37
+ "peerDependencies": {
38
+ "zhin.js": "^1.1.13",
39
+ "@zhin.js/command": "^1.1.9",
40
+ "@zhin.js/schedule-feature": "^1.1.7"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "@zhin.js/command": {
44
+ "optional": true
45
+ },
46
+ "@zhin.js/schedule-feature": {
47
+ "optional": true
48
+ }
49
+ },
50
+ "devDependencies": {
51
+ "typescript": "^6.0.3",
52
+ "vitest": "^4.1.10",
53
+ "@zhin.js/schedule-feature": "1.1.7",
54
+ "@zhin.js/testing": "1.0.5",
55
+ "zhin.js": "1.1.13"
56
+ },
57
+ "engines": {
58
+ "node": ">=22.19.0"
59
+ },
60
+ "license": "MIT",
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "git+https://github.com/zhinjs/zhin.git",
64
+ "directory": "plugins/utils/ledger"
65
+ },
66
+ "publishConfig": {
67
+ "access": "public",
68
+ "registry": "https://registry.npmjs.org"
69
+ },
70
+ "zhin": {
71
+ "protocol": 1,
72
+ "type": "plugin",
73
+ "entry": "./plugin.js",
74
+ "engine": "^1.0.0",
75
+ "runtime": "trusted",
76
+ "features": [
77
+ {
78
+ "package": "@zhin.js/command",
79
+ "api": "^1.0.0"
80
+ },
81
+ {
82
+ "package": "@zhin.js/schedule-feature",
83
+ "api": "^1.0.0"
84
+ }
85
+ ],
86
+ "plugins": []
87
+ },
88
+ "scripts": {
89
+ "build": "tsc",
90
+ "check": "tsc -p tsconfig.check.json",
91
+ "test": "vitest run --root ../../.. plugins/utils/ledger/tests"
92
+ }
6
93
  }
package/plugin.js ADDED
@@ -0,0 +1,19 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { databaseHostToken, definePlugin, outboundHostToken } from 'zhin.js';
3
+ import { resolveLedgerConfig } from "./lib/config.js";
4
+ import { ledgerServiceToken } from "./lib/runtime.js";
5
+ import { LedgerService } from "./lib/service.js";
6
+ import { defineLedgerTables } from "./lib/store.js";
7
+ export default definePlugin({
8
+ name: 'ledger',
9
+ metadata: { displayName: '记账' },
10
+ requires: [databaseHostToken],
11
+ setup(context) {
12
+ const config = resolveLedgerConfig(context.config.get());
13
+ const database = context.resources.use(databaseHostToken);
14
+ defineLedgerTables(database);
15
+ const outbound = context.resources.has(outboundHostToken) ? context.resources.use(outboundHostToken) : undefined;
16
+ const service = new LedgerService(database, outbound, config);
17
+ context.resources.provide(ledgerServiceToken, service);
18
+ },
19
+ });
@@ -0,0 +1,10 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { defineSchedule } from '@zhin.js/schedule-feature';
3
+ import { ledgerServiceToken } from "../../lib/runtime.js";
4
+ export default defineSchedule({
5
+ cron: '0 * * * * *',
6
+ description: '每分钟检查一次;到达 reportTime 后给已订阅用户私信当天记账日报,每人每天一次',
7
+ execute: async (context) => {
8
+ await context.use(ledgerServiceToken).runDailyReports();
9
+ },
10
+ });
@@ -0,0 +1,10 @@
1
+ import { defineSchedule } from '@zhin.js/schedule-feature';
2
+ import { ledgerServiceToken } from '../../src/runtime.js';
3
+
4
+ export default defineSchedule({
5
+ cron: '0 * * * * *',
6
+ description: '每分钟检查一次;到达 reportTime 后给已订阅用户私信当天记账日报,每人每天一次',
7
+ execute: async (context) => {
8
+ await context.use(ledgerServiceToken).runDailyReports();
9
+ },
10
+ });
package/schema.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "type": "object",
3
+ "additionalProperties": false,
4
+ "properties": {
5
+ "timezone": {
6
+ "type": "string",
7
+ "description": "计算今日、本周(周一开始)、本月与日报日期所用的 IANA 时区",
8
+ "default": "Asia/Shanghai",
9
+ "x-zhin-config": {
10
+ "effect": "reload"
11
+ }
12
+ },
13
+ "reportTime": {
14
+ "type": "string",
15
+ "description": "记账日报的发送时间(HH:MM,按 timezone);只发给用「记账日报 开启」订阅的用户",
16
+ "default": "22:00",
17
+ "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d$",
18
+ "x-zhin-config": {
19
+ "effect": "reload"
20
+ }
21
+ },
22
+ "reportWhenEmpty": {
23
+ "type": "boolean",
24
+ "description": "当天没有记账时是否仍发送日报",
25
+ "default": false,
26
+ "x-zhin-config": {
27
+ "effect": "reload"
28
+ }
29
+ }
30
+ }
31
+ }
package/src/config.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { Schema } from 'zhin.js';
2
+
3
+ export const DEFAULT_TIMEZONE = 'Asia/Shanghai';
4
+ export const DEFAULT_REPORT_TIME = '22:00';
5
+
6
+ export const ledgerConfig = Schema.object({
7
+ timezone: Schema.string().default(DEFAULT_TIMEZONE).description('计算今日、本周(周一开始)、本月与日报日期所用的 IANA 时区').effect('reload'),
8
+ reportTime: Schema.string().default(DEFAULT_REPORT_TIME).description('记账日报的发送时间(HH:MM,按 timezone);只发给用「记账日报 开启」订阅的用户').effect('reload'),
9
+ reportWhenEmpty: Schema.boolean().default(false).description('当天没有记账时是否仍发送日报').effect('reload'),
10
+ });
11
+
12
+ export type LedgerConfig = Schema.Output<typeof ledgerConfig>;
13
+
14
+ export interface ResolvedLedgerConfig {
15
+ readonly timezone: string;
16
+ readonly reportTime: string;
17
+ readonly reportWhenEmpty: boolean;
18
+ }
19
+
20
+ const REPORT_TIME_PATTERN = /^(?:[01]\d|2[0-3]):[0-5]\d$/u;
21
+
22
+ function assertTimezone(timezone: string): void {
23
+ try {
24
+ if (!timezone || timezone !== timezone.trim() || /^[+-]/u.test(timezone)) throw new RangeError('Invalid IANA timezone');
25
+ new Intl.DateTimeFormat('zh-CN', { timeZone: timezone }).format(0);
26
+ } catch {
27
+ throw new TypeError(`记账 timezone 必须是有效的 IANA 时区:${timezone}`);
28
+ }
29
+ }
30
+
31
+ /** Applies defaults and fails setup on an invalid timezone or report time instead of guessing. */
32
+ export function resolveLedgerConfig(config: Partial<LedgerConfig> | undefined): ResolvedLedgerConfig {
33
+ const timezone = config?.timezone ?? DEFAULT_TIMEZONE;
34
+ const reportTime = config?.reportTime ?? DEFAULT_REPORT_TIME;
35
+ const reportWhenEmpty = config?.reportWhenEmpty ?? false;
36
+ assertTimezone(timezone);
37
+ if (!REPORT_TIME_PATTERN.test(reportTime)) throw new TypeError(`记账 reportTime 必须是 HH:MM:${reportTime}`);
38
+ return { timezone, reportTime, reportWhenEmpty };
39
+ }
@@ -0,0 +1,39 @@
1
+ import { parseAmountToCents } from './money.js';
2
+
3
+ export const MAX_CATEGORY_LENGTH = 20;
4
+ export const MAX_NOTE_LENGTH = 100;
5
+
6
+ export const ADD_USAGE = '格式:记 <类别> <金额> [备注],例如:记 午饭 18.5 和同事';
7
+
8
+ export interface ParsedEntryInput {
9
+ readonly category: string;
10
+ readonly amountCents: number;
11
+ readonly note: string;
12
+ }
13
+
14
+ export type EntryInputResult =
15
+ | { readonly ok: true; readonly entry: ParsedEntryInput }
16
+ | { readonly ok: false; readonly reason: string };
17
+
18
+ function rejected(reason: string): EntryInputResult {
19
+ return { ok: false, reason };
20
+ }
21
+
22
+ /** Parses the text after `记`: category, amount, then an optional free-form note. */
23
+ export function parseEntryInput(text: string): EntryInputResult {
24
+ const words = text.trim().split(/\s+/u).filter((word) => word !== '');
25
+ if (words.length < 2) return rejected(ADD_USAGE);
26
+ const category = words[0]!;
27
+ const amountText = words[1]!;
28
+ const note = words.slice(2).join(' ');
29
+ const amount = parseAmountToCents(amountText);
30
+ if (!amount.ok) {
31
+ const categoryLooksLikeAmount = parseAmountToCents(category).ok;
32
+ if (categoryLooksLikeAmount) return rejected(`类别要写在金额前面。${ADD_USAGE}`);
33
+ return rejected(`${amount.reason}。${ADD_USAGE}`);
34
+ }
35
+ if (parseAmountToCents(category).ok) return rejected(`类别不能是数字。${ADD_USAGE}`);
36
+ if ([...category].length > MAX_CATEGORY_LENGTH) return rejected(`类别最多 ${MAX_CATEGORY_LENGTH} 个字`);
37
+ if ([...note].length > MAX_NOTE_LENGTH) return rejected(`备注最多 ${MAX_NOTE_LENGTH} 个字`);
38
+ return { ok: true, entry: { category, amountCents: amount.cents, note } };
39
+ }
@@ -0,0 +1,50 @@
1
+ import { createDeliveryTarget, type DeliveryTarget } from 'zhin.js';
2
+ import type { CommandMessage } from 'zhin.js/command';
3
+
4
+ /** The person using a ledger command and where they are talking from. */
5
+ export interface LedgerCaller {
6
+ /** Stable per-user key: adapter + endpoint (bot account) + sender id. */
7
+ readonly ownerKey: string;
8
+ readonly senderId: string;
9
+ /** The conversation the command came from. */
10
+ readonly conversation: DeliveryTarget;
11
+ /** The caller's own private conversation with the same bot account. */
12
+ readonly privateTarget: DeliveryTarget;
13
+ /** False in groups and channels, where other people can read replies. */
14
+ readonly isPrivate: boolean;
15
+ }
16
+
17
+ export class LedgerInputError extends Error {}
18
+
19
+ export function ledgerOwnerKey(adapter: string, endpointId: string, senderId: string): string {
20
+ return JSON.stringify([adapter, endpointId, senderId]);
21
+ }
22
+
23
+ /** Builds the caller from a command message; commands without a real sender are refused. */
24
+ export function ledgerCaller(input: CommandMessage | undefined): LedgerCaller {
25
+ const senderId = input?.sender?.id;
26
+ if (!input || !senderId) throw new LedgerInputError('记账需要在真实会话中由具体用户发送');
27
+ const conversation = createDeliveryTarget(input);
28
+ const endpoint = conversation.endpoint;
29
+ const isPrivate = conversation.kind === 'private';
30
+ const privateTarget: DeliveryTarget = isPrivate
31
+ ? conversation
32
+ : Object.freeze({ endpoint, kind: 'private', id: senderId });
33
+ return {
34
+ ownerKey: ledgerOwnerKey(endpoint.adapter, endpoint.id, senderId),
35
+ senderId,
36
+ conversation,
37
+ privateTarget,
38
+ isPrivate,
39
+ };
40
+ }
41
+
42
+ /** Turns expected input problems into a reply and lets real failures surface. */
43
+ export async function replyLedger(operation: () => Promise<string>): Promise<string> {
44
+ try {
45
+ return await operation();
46
+ } catch (error) {
47
+ if (error instanceof LedgerInputError) return error.message;
48
+ throw error;
49
+ }
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ export { ledgerConfig, resolveLedgerConfig, DEFAULT_REPORT_TIME, DEFAULT_TIMEZONE, type LedgerConfig, type ResolvedLedgerConfig } from './config.js';
2
+ export { parseEntryInput, ADD_USAGE, MAX_CATEGORY_LENGTH, MAX_NOTE_LENGTH, type EntryInputResult, type ParsedEntryInput } from './entry-input.js';
3
+ export { ledgerCaller, ledgerOwnerKey, replyLedger, LedgerInputError, type LedgerCaller } from './identity.js';
4
+ export { formatCents, parseAmountToCents, MASKED_AMOUNT, MAX_ENTRY_CENTS, type AmountParseResult } from './money.js';
5
+ export {
6
+ describePeriodRange,
7
+ isInPeriod,
8
+ localDateKey,
9
+ localTimeKey,
10
+ parsePeriodWord,
11
+ periodLabel,
12
+ weekEndKey,
13
+ weekStartKey,
14
+ type LedgerPeriod,
15
+ } from './period.js';
16
+ export {
17
+ formatCategoryStatistics,
18
+ formatEntryList,
19
+ formatPeriodSummary,
20
+ summarize,
21
+ totalsByCategory,
22
+ MAX_LISTED_ENTRIES,
23
+ type CategoryTotal,
24
+ type LedgerReply,
25
+ type PeriodSummary,
26
+ } from './report-format.js';
27
+ export { ledgerServiceToken } from './runtime.js';
28
+ export { LedgerService, type DailyReportAction, type LedgerServiceOptions } from './service.js';
29
+ export { defineLedgerTables, LedgerStore, ENTRY_TABLE, USER_TABLE, PRIVATE_TARGET_TABLE, type LedgerEntry, type LedgerUserRecord } from './store.js';
package/src/money.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Amounts are stored as integer cents (分) and parsed from text without floating point,
3
+ * so 0.1 + 0.2 style rounding errors cannot enter the ledger.
4
+ */
5
+
6
+ /** Largest single entry: ¥1,000,000.00, which also keeps sums far below Number.MAX_SAFE_INTEGER. */
7
+ export const MAX_ENTRY_CENTS = 100_000_000;
8
+
9
+ export type AmountParseResult =
10
+ | { readonly ok: true; readonly cents: number }
11
+ | { readonly ok: false; readonly reason: string };
12
+
13
+ const AMOUNT_PATTERN = /^(\d{1,7})(?:\.(\d{1,2}))?$/u;
14
+ const CURRENCY_PREFIX = /^[¥¥]/u;
15
+ const CURRENCY_SUFFIX = /(?:元|块|rmb|RMB|CNY)$/u;
16
+
17
+ /** Turns full-width digits and the full-width dot into ASCII so `12.5` parses like `12.5`. */
18
+ function toHalfWidth(text: string): string {
19
+ return text.replace(/[0-9.]/gu, (character) => {
20
+ if (character === '.') return '.';
21
+ const fullWidthZero = '0'.codePointAt(0)!;
22
+ return String(character.codePointAt(0)! - fullWidthZero);
23
+ });
24
+ }
25
+
26
+ function invalid(reason: string): AmountParseResult {
27
+ return { ok: false, reason };
28
+ }
29
+
30
+ /** Parses `18`, `18.5`, `18.50`, `¥18.5` or `18.5元` into cents; rejects negatives, zero, exponents and more than two decimals. */
31
+ export function parseAmountToCents(raw: string): AmountParseResult {
32
+ const normalized = toHalfWidth(raw.trim());
33
+ const withoutCurrency = normalized.replace(CURRENCY_PREFIX, '').replace(CURRENCY_SUFFIX, '').trim();
34
+ if (withoutCurrency.startsWith('-')) return invalid('金额不能为负数');
35
+ const match = AMOUNT_PATTERN.exec(withoutCurrency);
36
+ if (!match) return invalid('金额格式不正确,请写成 18 或 18.50(最多两位小数)');
37
+ const wholeYuan = Number(match[1]);
38
+ const fractionDigits = (match[2] ?? '').padEnd(2, '0');
39
+ const cents = wholeYuan * 100 + Number(fractionDigits);
40
+ if (cents === 0) return invalid('金额必须大于 0');
41
+ if (cents > MAX_ENTRY_CENTS) return invalid('单笔金额不能超过 ¥1000000.00');
42
+ return { ok: true, cents };
43
+ }
44
+
45
+ /** Formats cents as `¥12.50`. */
46
+ export function formatCents(cents: number): string {
47
+ const wholeYuan = Math.floor(cents / 100);
48
+ const remainingCents = cents % 100;
49
+ return `¥${wholeYuan}.${String(remainingCents).padStart(2, '0')}`;
50
+ }
51
+
52
+ /** The text shown instead of an amount where other people can read the reply. */
53
+ export const MASKED_AMOUNT = '¥***';