@wirecat/cli-core 0.556.0 → 0.558.0

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 (62) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/calendar/mcp/index.d.ts +12 -12
  3. package/dist/cli/index.d.ts +0 -1
  4. package/dist/cli/index.js +0 -1
  5. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  6. package/dist/domain/finance.d.ts +40 -0
  7. package/dist/domain/finance.js +39 -0
  8. package/dist/domain/money.d.ts +29 -0
  9. package/dist/domain/money.js +98 -0
  10. package/dist/domain/ref-summaries.js +2 -0
  11. package/dist/domain/references.d.ts +1 -1
  12. package/dist/domain/references.js +2 -0
  13. package/dist/entities/fact.d.ts +1 -12
  14. package/dist/entities/fact.js +0 -13
  15. package/dist/entities/index.d.ts +2 -12
  16. package/dist/entities/meeting-findings.d.ts +1 -1
  17. package/dist/errors/reasons.js +16 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.js +2 -0
  20. package/dist/mail/cli/review.js +0 -1
  21. package/dist/meetings/findings-rules.d.ts +7 -4
  22. package/dist/meetings/findings-rules.js +11 -4
  23. package/dist/meetings/mcp/index.d.ts +15 -15
  24. package/dist/meetings/payment.d.ts +10 -6
  25. package/dist/meetings/payment.js +21 -6
  26. package/dist/services/project-status/index.d.ts +1 -1
  27. package/dist/services/project-status/index.js +7 -3
  28. package/dist/services/project-status/signals/payments.d.ts +3 -3
  29. package/dist/services/project-status/signals/payments.js +43 -17
  30. package/dist/services/proposals/kinds/finance.d.ts +4 -0
  31. package/dist/services/proposals/kinds/finance.js +40 -0
  32. package/dist/services/proposals/registry.js +3 -0
  33. package/dist/store/evidence-findings.js +16 -8
  34. package/dist/store/index.d.ts +2 -1
  35. package/dist/store/project-transfer/request.d.ts +1 -1
  36. package/dist/store/project-transfer/schema.d.ts +2 -2
  37. package/dist/store/project-transfer/snapshot-model.d.ts +2 -2
  38. package/dist/store/sqlite/entity-types.d.ts +2 -0
  39. package/dist/store/sqlite/entity-types.js +2 -0
  40. package/dist/store/sqlite/facts.d.ts +0 -13
  41. package/dist/store/sqlite/facts.js +5 -58
  42. package/dist/store/sqlite/finance.d.ts +203 -0
  43. package/dist/store/sqlite/finance.js +529 -0
  44. package/dist/store/sqlite/findings-core.js +5 -14
  45. package/dist/store/sqlite/knowledge.js +3 -1
  46. package/dist/store/sqlite/manifest.js +3 -0
  47. package/dist/store/sqlite/migrations.generated.js +43 -0
  48. package/dist/store/sqlite/open-store.js +2 -0
  49. package/dist/store/sqlite/payment-entries.d.ts +26 -0
  50. package/dist/store/sqlite/payment-entries.js +91 -0
  51. package/dist/store/sqlite/payment-facts.d.ts +12 -0
  52. package/dist/store/sqlite/payment-facts.js +92 -0
  53. package/dist/store/sqlite/project-transfer/snapshot.d.ts +1 -1
  54. package/dist/store/sqlite/ref-summaries.js +33 -0
  55. package/dist/store/sqlite/schema.d.ts +1043 -0
  56. package/dist/store/sqlite/schema.js +98 -0
  57. package/dist/store/sqlite/things.js +2 -0
  58. package/dist/store/store.d.ts +3 -1
  59. package/dist/store/store.js +5 -0
  60. package/package.json +1 -1
  61. package/dist/cli/payment-query.d.ts +0 -8
  62. package/dist/cli/payment-query.js +0 -27
@@ -0,0 +1,529 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { FINANCE_REVIEWS, INVOICE_DIRECTIONS, INVOICE_STATUSES, invoiceTitle, MONEY_DIRECTIONS, paymentEntryTitle, settledStatus, } from "../../domain/finance.js";
3
+ import { currencyOf, formatDecimal, lineUnits, minorUnits, moneyLabel, moneyOf, quantityOf, scaleOf, } from "../../domain/money.js";
4
+ import { assertMayDecide, botKey, deciderAuthor } from "../decisions.js";
5
+ import { proposedBySql, proposerOf } from "./actors.js";
6
+ import { authorActor, authorOf, evidenceOf, linkEvidence, projectId, projectOf, subjectOf, } from "./memories.js";
7
+ import { requiredThing } from "./notes.js";
8
+ import { write } from "./transaction.js";
9
+ const refuse = (message, reason, code = "validation_error") => {
10
+ throw new CliError(code, message, { reason });
11
+ };
12
+ const oneOf = (value, allowed, what) => {
13
+ if (typeof value !== "string" || !allowed.includes(value))
14
+ return refuse(`${what} is one of ${allowed.join(", ")}`, "finance.field");
15
+ return value;
16
+ };
17
+ const text = (value, what, limit) => {
18
+ if (value === undefined || value === null)
19
+ return null;
20
+ if (typeof value !== "string" || !value.trim() || value.length > limit || value.includes("\0"))
21
+ return refuse(`${what} takes 1–${limit} characters`, "finance.field");
22
+ return value.trim();
23
+ };
24
+ const isoDate = (value, what) => {
25
+ if (value === undefined || value === null)
26
+ return null;
27
+ const date = typeof value === "string" ? value.trim() : "";
28
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) !== date)
29
+ return refuse(`${what} is a date such as 2026-11-15`, "finance.field");
30
+ return date;
31
+ };
32
+ const iso = (value) => new Date(Number(value)).toISOString();
33
+ const evidenceList = (value) => {
34
+ if (value === undefined)
35
+ return [];
36
+ if (!Array.isArray(value) || value.length > 8 || value.some((one) => typeof one !== "string" || !one.trim()))
37
+ return refuse("evidence is a list of at most 8 references", "finance.field");
38
+ return [...new Set(value.map((one) => one.trim()))];
39
+ };
40
+ const MAX_LINES = 500;
41
+ const reasonOf = (metadata) => {
42
+ if (metadata == null)
43
+ return null;
44
+ try {
45
+ const { reason } = JSON.parse(String(metadata));
46
+ return typeof reason === "string" ? reason : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ };
52
+ /** Money settled by confirmed payments only: a proposed payment promises nothing yet. */
53
+ const PAID = "(SELECT coalesce(sum(a.amount), 0) FROM payment_allocations a JOIN payments p ON p.id = a.payment_id " +
54
+ "WHERE a.invoice_id = i.id AND p.review = 'confirmed' AND p.deleted_at IS NULL)";
55
+ const INVOICES = `SELECT i.*, ${PAID} AS paid, b.name AS author_name, b.kind AS author_kind FROM invoices i ` +
56
+ "LEFT JOIN bots b ON i.author_type = 'bot' AND b.id = i.author_id";
57
+ const PAYMENTS = "SELECT p.*, (SELECT coalesce(sum(amount), 0) FROM payment_allocations WHERE payment_id = p.id) AS allocated, " +
58
+ "b.name AS author_name, b.kind AS author_kind FROM payments p LEFT JOIN bots b ON p.author_type = 'bot' AND b.id = p.author_id";
59
+ export const financeOperationsOver = (context) => {
60
+ const { database, now } = context;
61
+ const partyOf = (type, id) => {
62
+ if (type == null)
63
+ return null;
64
+ const { subject, subjectName, subjectIsOwner } = subjectOf(database, { subject_type: type, subject_id: id });
65
+ return subject === null ? null : { ref: subject, name: subjectName, owner: subjectIsOwner };
66
+ };
67
+ const partyThing = (reference, what) => {
68
+ if (reference === undefined)
69
+ return undefined;
70
+ const thing = requiredThing(database, reference, what);
71
+ if (thing.type !== "person" && thing.type !== "organization")
72
+ return refuse(`${what} is a person: or organization: reference, not ${reference}`, "finance.party");
73
+ return thing;
74
+ };
75
+ const reviewedOf = (row, type) => ({
76
+ review: row.review,
77
+ author: authorOf(row),
78
+ model: row.model == null ? null : String(row.model),
79
+ confirmedBy: row.confirmed_by_type == null
80
+ ? null
81
+ : { type: row.confirmed_by_type, id: String(row.confirmed_by_id) },
82
+ source: String(row.source),
83
+ reason: reasonOf(row.metadata),
84
+ evidence: evidenceOf(context, { type, id: Number(row.id) }),
85
+ ...projectOf(database, row),
86
+ createdAt: iso(row.created_at),
87
+ updatedAt: iso(row.updated_at),
88
+ });
89
+ const invoiceOf = (row) => {
90
+ const currency = String(row.currency);
91
+ const scale = scaleOf(currency);
92
+ const [total, paid] = [Number(row.total), Number(row.paid)];
93
+ const counterparty = partyOf(row.counterparty_type, row.counterparty_id);
94
+ const fields = {
95
+ id: Number(row.id),
96
+ number: row.number == null ? null : String(row.number),
97
+ direction: row.direction,
98
+ total,
99
+ currency,
100
+ counterpartyName: counterparty?.name ?? null,
101
+ dueDate: row.due_date == null ? null : String(row.due_date),
102
+ status: row.status,
103
+ review: row.review,
104
+ };
105
+ return {
106
+ id: String(row.id),
107
+ ref: `invoice:${row.id}`,
108
+ title: invoiceTitle(fields),
109
+ direction: fields.direction,
110
+ number: fields.number,
111
+ issuer: partyOf(row.issuer_type, row.issuer_id),
112
+ counterparty,
113
+ currency,
114
+ total: formatDecimal(total, scale),
115
+ paid: formatDecimal(paid, scale),
116
+ open: formatDecimal(Math.max(total - paid, 0), scale),
117
+ issueDate: row.issue_date == null ? null : String(row.issue_date),
118
+ dueDate: fields.dueDate,
119
+ status: fields.status,
120
+ ...reviewedOf(row, "invoice"),
121
+ };
122
+ };
123
+ const paymentOf = (row) => {
124
+ const currency = String(row.currency);
125
+ const scale = scaleOf(currency);
126
+ const [amount, allocated] = [Number(row.amount), Number(row.allocated)];
127
+ const direction = row.direction;
128
+ const payer = partyOf(row.payer_type, row.payer_id);
129
+ const payee = partyOf(row.payee_type, row.payee_id);
130
+ const date = String(row.payment_date);
131
+ return {
132
+ id: String(row.id),
133
+ ref: `payment:${row.id}`,
134
+ title: paymentEntryTitle({
135
+ direction,
136
+ amount,
137
+ currency,
138
+ partyName: (direction === "in" ? payer : payee)?.name ?? null,
139
+ date,
140
+ review: row.review,
141
+ }),
142
+ direction,
143
+ payer,
144
+ payee,
145
+ amount: formatDecimal(amount, scale),
146
+ currency,
147
+ date,
148
+ reference: row.reference == null ? null : String(row.reference),
149
+ allocated: formatDecimal(allocated, scale),
150
+ unallocated: formatDecimal(amount - allocated, scale),
151
+ ...reviewedOf(row, "payment"),
152
+ };
153
+ };
154
+ const allocationsWhere = (column, id) => database
155
+ .prepare("SELECT a.*, p.currency FROM payment_allocations a JOIN payments p ON p.id = a.payment_id " +
156
+ `WHERE a.${column} = ? ORDER BY a.id`)
157
+ .all(id)
158
+ .map((row) => ({
159
+ payment: `payment:${row.payment_id}`,
160
+ invoice: `invoice:${row.invoice_id}`,
161
+ amount: formatDecimal(Number(row.amount), scaleOf(String(row.currency))),
162
+ currency: String(row.currency),
163
+ createdAt: iso(row.created_at),
164
+ }));
165
+ const rowOf = (reference, type) => {
166
+ const thing = requiredThing(database, reference.includes(":") ? reference : `${type}:${reference}`, `the ${type}`);
167
+ if (thing.type !== type)
168
+ return refuse(`${reference} is not an ${type}`, "finance.ref");
169
+ return database.prepare(`${type === "invoice" ? INVOICES : PAYMENTS} WHERE ${type[0]}.id = ?`).get(thing.id);
170
+ };
171
+ const invoiceDetail = (reference) => {
172
+ const row = rowOf(reference, "invoice");
173
+ const scale = scaleOf(String(row.currency));
174
+ const lines = database
175
+ .prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY position")
176
+ .all(Number(row.id))
177
+ .map((line) => ({
178
+ position: Number(line.position),
179
+ description: String(line.description),
180
+ quantity: String(line.quantity),
181
+ unitPrice: line.unit_price == null ? null : formatDecimal(Number(line.unit_price), scale),
182
+ amount: formatDecimal(Number(line.amount), scale),
183
+ }));
184
+ return { ...invoiceOf(row), lines, allocations: allocationsWhere("invoice_id", Number(row.id)) };
185
+ };
186
+ const paymentDetail = (reference) => {
187
+ const row = rowOf(reference, "payment");
188
+ return { ...paymentOf(row), allocations: allocationsWhere("payment_id", Number(row.id)) };
189
+ };
190
+ const settle = (invoiceId) => {
191
+ const row = database
192
+ .prepare(`SELECT i.status, i.total, ${PAID} AS paid FROM invoices i WHERE i.id = ?`)
193
+ .get(invoiceId);
194
+ if (!row)
195
+ return;
196
+ const status = settledStatus(row.status, Number(row.total), Number(row.paid));
197
+ database
198
+ .prepare("UPDATE invoices SET status = ?, updated_at = ? WHERE id = ? AND status <> ?")
199
+ .run(status, now(), invoiceId, status);
200
+ };
201
+ const allocateRow = (payment, reference, units, actor) => {
202
+ const invoice = rowOf(reference, "invoice");
203
+ const [paymentRef, invoiceRef] = [`payment:${payment.id}`, `invoice:${invoice.id}`];
204
+ if (invoice.deleted_at != null || invoice.review === "rejected")
205
+ refuse(`${invoiceRef} is ${invoice.review === "rejected" ? "rejected" : "deleted"}`, "finance.allocate");
206
+ if (invoice.status === "draft" || invoice.status === "void")
207
+ refuse(`${invoiceRef} is ${invoice.status}; only an issued invoice is paid`, "finance.allocate");
208
+ const currency = String(payment.currency);
209
+ if (invoice.currency !== currency)
210
+ refuse(`${paymentRef} is in ${currency} and ${invoiceRef} in ${String(invoice.currency)}; the store converts nothing`, "finance.currency");
211
+ const fromPayment = Number(database
212
+ .prepare("SELECT coalesce(sum(amount), 0) AS n FROM payment_allocations WHERE payment_id = ?")
213
+ .get(Number(payment.id))?.n);
214
+ if (fromPayment + units > Number(payment.amount))
215
+ refuse(`${paymentRef} is ${moneyLabel(Number(payment.amount), currency)} and ` +
216
+ `${moneyLabel(fromPayment, currency)} of it is already allocated; ${moneyLabel(units, currency)} more is too much`, "finance.over-payment");
217
+ const toInvoice = Number(database
218
+ .prepare("SELECT coalesce(sum(a.amount), 0) AS n FROM payment_allocations a JOIN payments p ON p.id = a.payment_id " +
219
+ "WHERE a.invoice_id = ? AND ((p.review = 'confirmed' AND p.deleted_at IS NULL) OR a.payment_id = ?)")
220
+ .get(Number(invoice.id), Number(payment.id))?.n);
221
+ if (toInvoice + units > Number(invoice.total))
222
+ refuse(`${invoiceRef} totals ${moneyLabel(Number(invoice.total), currency)} and ` +
223
+ `${moneyLabel(toInvoice, currency)} is already allocated to it; ${moneyLabel(units, currency)} more is too much`, "finance.over-invoice");
224
+ const at = now();
225
+ database
226
+ .prepare("INSERT INTO payment_allocations (payment_id, invoice_id, amount, author_type, author_id, created_at, updated_at) " +
227
+ "VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (payment_id, invoice_id) " +
228
+ "DO UPDATE SET amount = amount + excluded.amount, updated_at = excluded.updated_at")
229
+ .run(Number(payment.id), Number(invoice.id), units, actor.type, actor.id, at, at);
230
+ return Number(invoice.id);
231
+ };
232
+ const written = (input) => {
233
+ const owner = input.by === "owner";
234
+ const { actor, model } = authorActor(context, input.by);
235
+ const source = owner ? "owner" : (text(input.source, "a source", 80) ?? "agent");
236
+ const reason = owner ? null : text(input.reason, "a reason", 2000);
237
+ return {
238
+ owner,
239
+ actor,
240
+ model,
241
+ source,
242
+ review: owner ? "confirmed" : "proposed",
243
+ confirmedType: owner ? actor.type : null,
244
+ confirmedId: owner ? actor.id : null,
245
+ metadata: reason === null ? null : JSON.stringify({ reason }),
246
+ project: input.project === undefined ? null : projectId(database, input.project),
247
+ evidence: evidenceList(input.evidence),
248
+ };
249
+ };
250
+ const keepEvidence = (type, id, by) => {
251
+ if (by.evidence.length)
252
+ linkEvidence(context, { type, id }, by.evidence, by.owner ? "owner" : "agent");
253
+ };
254
+ const ownerOnly = (by, what) => {
255
+ if (by !== "owner")
256
+ refuse(`only the owner ${what}`, "finance.owner-only", "permission_error");
257
+ };
258
+ const keepDecision = (table, id, by, reason) => {
259
+ const why = [by === "owner" ? undefined : by.reason.trim(), reason?.trim() || undefined].filter(Boolean).join(": ");
260
+ if (by === "owner" && !why)
261
+ return;
262
+ const decision = by === "owner" ? { by: "owner", reason: why } : { by: "agent", bot: by.bot, reason: why };
263
+ database
264
+ .prepare(`UPDATE ${table} SET metadata = json_set(coalesce(metadata, '{}'), '$.decision', json(?)) WHERE id = ?`)
265
+ .run(JSON.stringify(decision), id);
266
+ };
267
+ /** A proposed payment's allocations count once confirmed; by then other confirmed payments may cover the invoice. */
268
+ const coveredElsewhere = (paymentId) => {
269
+ const over = database
270
+ .prepare("SELECT a.invoice_id, a.amount, i.total, i.currency, (SELECT coalesce(sum(o.amount), 0) FROM payment_allocations o " +
271
+ "JOIN payments p ON p.id = o.payment_id WHERE o.invoice_id = a.invoice_id AND o.payment_id <> a.payment_id " +
272
+ "AND p.review = 'confirmed' AND p.deleted_at IS NULL) AS others FROM payment_allocations a " +
273
+ "JOIN invoices i ON i.id = a.invoice_id WHERE a.payment_id = ? AND a.amount + others > i.total LIMIT 1")
274
+ .get(paymentId);
275
+ if (!over)
276
+ return;
277
+ const currency = String(over.currency);
278
+ refuse(`invoice:${over.invoice_id} is already covered by other payments: ${moneyLabel(Number(over.others), currency)} ` +
279
+ `of ${moneyLabel(Number(over.total), currency)}; payment:${paymentId} would add ` +
280
+ `${moneyLabel(Number(over.amount), currency)}, so it stays proposed`, "finance.over-invoice");
281
+ };
282
+ const decide = (reference, by, reason, confirm) => {
283
+ const thing = requiredThing(database, reference, "the proposal");
284
+ if (thing.type !== "invoice" && thing.type !== "payment")
285
+ return refuse(`${reference} is not an invoice: or payment:`, "finance.ref");
286
+ const type = thing.type;
287
+ const table = type === "invoice" ? "invoices" : "payments";
288
+ const row = rowOf(reference, type);
289
+ const ref = `${type}:${row.id}`;
290
+ assertMayDecide({ id: ref, proposedBy: proposerOf(database, row.author_type, row.author_id), ownerOnly: true }, by, context.decisions);
291
+ if (row.review !== "proposed")
292
+ refuse(`${ref} is already ${String(row.review)}`, "proposal.wrong-state");
293
+ const id = Number(row.id);
294
+ write(database, () => {
295
+ const at = now();
296
+ if (confirm && type === "payment")
297
+ coveredElsewhere(id);
298
+ if (confirm) {
299
+ const { actor } = authorActor(context, deciderAuthor(by));
300
+ database
301
+ .prepare(`UPDATE ${table} SET review = 'confirmed', confirmed_by_type = ?, confirmed_by_id = ?, updated_at = ? WHERE id = ?`)
302
+ .run(actor.type, actor.id, at, id);
303
+ }
304
+ else
305
+ database.prepare(`UPDATE ${table} SET review = 'rejected', updated_at = ? WHERE id = ?`).run(at, id);
306
+ keepDecision(table, id, by, reason);
307
+ const touched = type === "invoice"
308
+ ? [id]
309
+ : database
310
+ .prepare("SELECT invoice_id FROM payment_allocations WHERE payment_id = ?")
311
+ .all(id)
312
+ .map((one) => Number(one.invoice_id));
313
+ if (!confirm)
314
+ database
315
+ .prepare(`DELETE FROM payment_allocations WHERE ${type === "invoice" ? "invoice_id" : "payment_id"} = ?`)
316
+ .run(id);
317
+ for (const invoice of touched)
318
+ settle(invoice);
319
+ });
320
+ return type === "invoice" ? invoiceDetail(ref) : paymentDetail(ref);
321
+ };
322
+ const limitOf = (limit) => Math.min(Math.max(limit ?? 100, 1), 500);
323
+ return {
324
+ addInvoice: (input) => {
325
+ const direction = oneOf(input.direction, INVOICE_DIRECTIONS, "an invoice's direction");
326
+ const currency = currencyOf(input.currency);
327
+ if (!Array.isArray(input.lines) || input.lines.length === 0 || input.lines.length > MAX_LINES)
328
+ refuse(`an invoice has 1–${MAX_LINES} lines`, "finance.lines");
329
+ const lines = input.lines.map((line, at) => {
330
+ const what = `line ${at + 1}`;
331
+ const quantity = quantityOf(line.quantity ?? "1");
332
+ const unitPrice = line.unitPrice === undefined ? null : minorUnits(line.unitPrice, currency, `${what}'s unit price`);
333
+ const computed = unitPrice === null ? null : lineUnits(quantity.thousandths, unitPrice);
334
+ const amount = line.amount === undefined ? computed : minorUnits(line.amount, currency, `${what}'s amount`);
335
+ if (amount === null)
336
+ return refuse(`${what} takes a unit price, an amount or both`, "finance.lines");
337
+ if (computed !== null && computed !== amount)
338
+ refuse(`${what}: ${quantity.text} × ${moneyLabel(unitPrice, currency)} is ` +
339
+ `${moneyLabel(computed, currency)}, not ${moneyLabel(amount, currency)}`, "finance.line-amount");
340
+ return {
341
+ description: text(line.description, `${what}'s description`, 500) ??
342
+ refuse(`${what} needs a description`, "finance.lines"),
343
+ quantity: quantity.text,
344
+ unitPrice: unitPrice ?? (quantity.thousandths === 1000n ? amount : null),
345
+ amount,
346
+ };
347
+ });
348
+ const total = lines.reduce((sum, line) => sum + line.amount, 0);
349
+ if (!Number.isSafeInteger(total))
350
+ refuse("an invoice's total is too large", "finance.total");
351
+ if (total <= 0)
352
+ refuse("an invoice totals more than 0", "finance.total");
353
+ if (input.total !== undefined) {
354
+ const printed = minorUnits(input.total, currency, "an invoice's total");
355
+ if (printed !== total)
356
+ refuse(`the invoice's total ${moneyLabel(printed, currency)} is not the sum of its lines, ${moneyLabel(total, currency)}`, "finance.total");
357
+ }
358
+ const number = text(input.number, "an invoice's number", 80);
359
+ const issueDate = isoDate(input.issueDate, "an invoice's issue date");
360
+ const dueDate = isoDate(input.dueDate, "an invoice's due date");
361
+ const id = write(database, () => {
362
+ const issuer = partyThing(input.issuer, "the issuer");
363
+ const counterparty = partyThing(input.counterparty, "the counterparty") ??
364
+ refuse("an invoice names its counterparty, a person: or organization:", "finance.party");
365
+ const by = written(input);
366
+ const at = now();
367
+ const made = Number(database
368
+ .prepare("INSERT INTO invoices (direction, number, issuer_type, issuer_id, counterparty_type, counterparty_id, project_id, " +
369
+ "currency, total, issue_date, due_date, status, review, author_type, author_id, model, confirmed_by_type, " +
370
+ "confirmed_by_id, source, metadata, created_at, updated_at) " +
371
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
372
+ .get(direction, number, issuer?.type ?? null, issuer?.id ?? null, counterparty.type, counterparty.id, by.project, currency, total, issueDate, dueDate, input.draft ? "draft" : "issued", by.review, by.actor.type, by.actor.id, by.model, by.confirmedType, by.confirmedId, by.source, by.metadata, at, at)?.id);
373
+ const insert = database.prepare("INSERT INTO invoice_lines (invoice_id, position, description, quantity, unit_price, amount, created_at, updated_at) " +
374
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
375
+ for (const [index, line] of lines.entries())
376
+ insert.run(made, index + 1, line.description, line.quantity, line.unitPrice, line.amount, at, at);
377
+ keepEvidence("invoice", made, by);
378
+ return made;
379
+ });
380
+ return invoiceDetail(`invoice:${id}`);
381
+ },
382
+ invoice: (reference) => invoiceDetail(reference),
383
+ invoices: (filter = {}) => {
384
+ const project = filter.project === undefined ? null : projectId(database, filter.project);
385
+ const counterparty = partyThing(filter.counterparty, "the counterparty");
386
+ const by = proposedBySql("i.author_type", "i.author_id", filter.proposedBy);
387
+ const status = filter.status === undefined ? null : oneOf(filter.status, INVOICE_STATUSES, "a status");
388
+ const review = filter.review === undefined ? null : oneOf(filter.review, FINANCE_REVIEWS, "a review");
389
+ return database
390
+ .prepare(`${INVOICES} WHERE i.deleted_at IS NULL AND (? IS NULL OR i.direction = ?) AND (? IS NULL OR i.status = ?) ` +
391
+ "AND (? IS NULL OR i.review = ?) AND (? IS NULL OR i.project_id = ?) " +
392
+ "AND (? IS NULL OR (i.counterparty_type = ? AND i.counterparty_id = ?)) " +
393
+ `${filter.open ? "AND i.status IN ('issued', 'partly_paid') " : ""}AND ${by.sql} ` +
394
+ "ORDER BY i.created_at DESC, i.id DESC LIMIT ?")
395
+ .all(filter.direction ?? null, filter.direction ?? null, status, status, review, review, project, project, counterparty?.type ?? null, counterparty?.type ?? null, counterparty?.id ?? null, ...by.params, limitOf(filter.limit))
396
+ .map(invoiceOf);
397
+ },
398
+ issueInvoice: (reference, by) => {
399
+ ownerOnly(by, "issues an invoice");
400
+ const row = rowOf(reference, "invoice");
401
+ if (row.status !== "draft")
402
+ refuse(`invoice:${row.id} is ${String(row.status)}, not a draft`, "finance.status");
403
+ write(database, () => {
404
+ database
405
+ .prepare("UPDATE invoices SET status = 'issued', updated_at = ? WHERE id = ?")
406
+ .run(now(), Number(row.id));
407
+ settle(Number(row.id));
408
+ });
409
+ return invoiceDetail(`invoice:${row.id}`);
410
+ },
411
+ voidInvoice: (reference, by) => {
412
+ ownerOnly(by, "voids an invoice");
413
+ const row = rowOf(reference, "invoice");
414
+ if (row.status === "void")
415
+ refuse(`invoice:${row.id} is already void`, "finance.status");
416
+ const allocated = database
417
+ .prepare("SELECT 1 FROM payment_allocations a JOIN payments p ON p.id = a.payment_id " +
418
+ "WHERE a.invoice_id = ? AND p.review = 'confirmed' AND p.deleted_at IS NULL LIMIT 1")
419
+ .get(Number(row.id));
420
+ if (allocated)
421
+ refuse(`payments are allocated to invoice:${row.id}; a paid invoice is not voided`, "finance.status");
422
+ write(database, () => {
423
+ database
424
+ .prepare("DELETE FROM payment_allocations WHERE invoice_id = ? " +
425
+ "AND payment_id IN (SELECT id FROM payments WHERE review = 'proposed')")
426
+ .run(Number(row.id));
427
+ database.prepare("UPDATE invoices SET status = 'void', updated_at = ? WHERE id = ?").run(now(), Number(row.id));
428
+ });
429
+ return invoiceDetail(`invoice:${row.id}`);
430
+ },
431
+ addPayment: (input) => {
432
+ const direction = oneOf(input.direction, MONEY_DIRECTIONS, "a payment's direction");
433
+ const currency = currencyOf(input.currency);
434
+ const amount = minorUnits(input.amount, currency, "a payment's amount");
435
+ if (amount <= 0)
436
+ refuse("a payment's amount is more than 0", "money.amount");
437
+ const date = isoDate(input.date, "a payment's date") ?? refuse("a payment has a date", "finance.field");
438
+ const reference = text(input.reference, "a payment's reference", 500);
439
+ const allocations = (input.allocations ?? []).map(({ invoice, amount: share }) => ({
440
+ invoice,
441
+ units: minorUnits(share, currency, `the amount allocated to ${invoice}`),
442
+ }));
443
+ const id = write(database, () => {
444
+ const payer = partyThing(input.payer, "the payer");
445
+ const payee = partyThing(input.payee, "the payee");
446
+ const by = written(input);
447
+ const at = now();
448
+ const made = database
449
+ .prepare("INSERT INTO payments (direction, payer_type, payer_id, payee_type, payee_id, amount, currency, payment_date, " +
450
+ "reference, project_id, review, author_type, author_id, model, confirmed_by_type, confirmed_by_id, source, " +
451
+ "metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *")
452
+ .get(direction, payer?.type ?? null, payer?.id ?? null, payee?.type ?? null, payee?.id ?? null, amount, currency, date, reference, by.project, by.review, by.actor.type, by.actor.id, by.model, by.confirmedType, by.confirmedId, by.source, by.metadata, at, at);
453
+ keepEvidence("payment", Number(made.id), by);
454
+ const touched = allocations.map(({ invoice, units }) => allocateRow(made, invoice, units, by.actor));
455
+ for (const invoice of touched)
456
+ settle(invoice);
457
+ return Number(made.id);
458
+ });
459
+ return paymentDetail(`payment:${id}`);
460
+ },
461
+ payment: (reference) => paymentDetail(reference),
462
+ payments: (filter = {}) => {
463
+ const project = filter.project === undefined ? null : projectId(database, filter.project);
464
+ const party = partyThing(filter.party, "the party");
465
+ const by = proposedBySql("p.author_type", "p.author_id", filter.proposedBy);
466
+ const review = filter.review === undefined ? null : oneOf(filter.review, FINANCE_REVIEWS, "a review");
467
+ return database
468
+ .prepare(`${PAYMENTS} WHERE p.deleted_at IS NULL AND (? IS NULL OR p.direction = ?) AND (? IS NULL OR p.review = ?) ` +
469
+ "AND (? IS NULL OR p.project_id = ?) AND (? IS NULL OR (p.payer_type = ? AND p.payer_id = ?) " +
470
+ `OR (p.payee_type = ? AND p.payee_id = ?)) AND ${by.sql} ORDER BY p.created_at DESC, p.id DESC LIMIT ?`)
471
+ .all(filter.direction ?? null, filter.direction ?? null, review, review, project, project, party?.type ?? null, party?.type ?? null, party?.id ?? null, party?.type ?? null, party?.id ?? null, ...by.params, limitOf(filter.limit))
472
+ .map(paymentOf);
473
+ },
474
+ allocate: ({ payment, invoice, amount, by }) => {
475
+ const row = rowOf(payment, "payment");
476
+ const ref = `payment:${row.id}`;
477
+ if (by !== "owner") {
478
+ const own = row.author_type === "bot" && row.author_name != null && botKey(String(row.author_name)) === botKey(by.bot);
479
+ if (row.review !== "proposed" || !own)
480
+ refuse(`only the owner allocates ${ref}; a bot allocates only from a payment it proposed that is still proposed`, "finance.owner-only", "permission_error");
481
+ }
482
+ if (row.review === "rejected" || row.deleted_at != null)
483
+ refuse(`${ref} is ${String(row.review)}`, "finance.allocate");
484
+ const currency = String(row.currency);
485
+ const units = minorUnits(amount, currency, "an allocation");
486
+ if (units <= 0)
487
+ refuse("an allocation is more than 0", "money.amount");
488
+ const invoiceId = write(database, () => {
489
+ const { actor } = authorActor(context, by);
490
+ const made = allocateRow(row, invoice, units, actor);
491
+ settle(made);
492
+ return made;
493
+ });
494
+ const found = allocationsWhere("payment_id", Number(row.id)).find((one) => one.invoice === `invoice:${invoiceId}`);
495
+ return found;
496
+ },
497
+ confirm: (reference, by, reason) => decide(reference, by, reason, true),
498
+ reject: (reference, by, reason) => decide(reference, by, reason, false),
499
+ openTotals: (filter = {}) => {
500
+ const project = filter.project === undefined ? null : projectId(database, filter.project);
501
+ const rows = database
502
+ .prepare(`SELECT i.direction, i.currency, sum(i.total - ${PAID}) AS open, count(*) AS n FROM invoices i ` +
503
+ "WHERE i.review = 'confirmed' AND i.deleted_at IS NULL AND i.status IN ('issued', 'partly_paid') " +
504
+ "AND (? IS NULL OR i.project_id = ?) GROUP BY i.direction, i.currency ORDER BY i.currency")
505
+ .all(project, project);
506
+ const totals = (direction) => rows
507
+ .filter((row) => row.direction === direction)
508
+ .map((row) => ({ ...moneyOf(Number(row.open), String(row.currency)), invoices: Number(row.n) }));
509
+ return { receivable: totals("receivable"), payable: totals("payable") };
510
+ },
511
+ };
512
+ };
513
+ export const financeStoreOver = (context) => {
514
+ const operations = financeOperationsOver(context);
515
+ return {
516
+ addInvoice: async (input) => operations.addInvoice(input),
517
+ invoice: async (reference) => operations.invoice(reference),
518
+ invoices: async (filter) => operations.invoices(filter),
519
+ issueInvoice: async (reference, by) => operations.issueInvoice(reference, by),
520
+ voidInvoice: async (reference, by) => operations.voidInvoice(reference, by),
521
+ addPayment: async (input) => operations.addPayment(input),
522
+ payment: async (reference) => operations.payment(reference),
523
+ payments: async (filter) => operations.payments(filter),
524
+ allocate: async (input) => operations.allocate(input),
525
+ confirm: async (reference, by, reason) => operations.confirm(reference, by, reason),
526
+ reject: async (reference, by, reason) => operations.reject(reference, by, reason),
527
+ openTotals: async (filter) => operations.openTotals(filter),
528
+ };
529
+ };
@@ -1,8 +1,9 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { findingFingerprint, findingHash, paymentTitle, } from "../../meetings/index.js";
2
+ import { findingFingerprint, findingHash, } from "../../meetings/index.js";
3
3
  import { factOperationsOver } from "./facts.js";
4
4
  import { linkEvidence } from "./memories.js";
5
5
  import { requiredThing } from "./notes.js";
6
+ import { paymentEntriesOver } from "./payment-entries.js";
6
7
  import { taskRowOf } from "./things.js";
7
8
  import { workTaskOperationsOver } from "./work-tasks.js";
8
9
  const invalid = (message) => {
@@ -27,6 +28,7 @@ export const findingsCoreOver = (context, kind) => {
27
28
  const { database } = context;
28
29
  const facts = factOperationsOver(context);
29
30
  const tasks = workTaskOperationsOver(context);
31
+ const recordPayment = paymentEntriesOver(context);
30
32
  const cited = (input, cite) => {
31
33
  const seen = new Map();
32
34
  for (const finding of input.findings) {
@@ -81,19 +83,8 @@ export const findingsCoreOver = (context, kind) => {
81
83
  });
82
84
  return facts.acceptChecked(fact.ref, decidedBy).ref;
83
85
  }
84
- if (finding.kind === "payment") {
85
- const fact = facts.add({
86
- type: "payment",
87
- statement: finding.statement ?? paymentTitle(finding),
88
- payment: finding.payment,
89
- ...(finding.project === undefined ? {} : { project: finding.project }),
90
- ...(finding.subject === undefined ? {} : { subject: finding.subject }),
91
- ...(finding.supersedes === undefined ? {} : { supersedes: finding.supersedes }),
92
- evidence,
93
- by: agent,
94
- });
95
- return facts.acceptChecked(fact.ref, decidedBy).ref;
96
- }
86
+ if (finding.kind === "payment")
87
+ return recordPayment({ ...finding, evidence }, agent, { confirm: true, by: decidedBy });
97
88
  if (finding.project === undefined)
98
89
  return invalid("a task finding names the project the task goes in");
99
90
  const task = tasks.create({
@@ -148,7 +148,9 @@ export const knowledgeStoreOver = (context) => {
148
148
  parsed.type === "fact" ||
149
149
  parsed.type === "bot" ||
150
150
  parsed.type === "event" ||
151
- parsed.type === "meeting")
151
+ parsed.type === "meeting" ||
152
+ parsed.type === "invoice" ||
153
+ parsed.type === "payment")
152
154
  return undefined;
153
155
  return { type: parsed.type, id: parsed.id };
154
156
  };
@@ -59,6 +59,9 @@ export const MANIFEST = [
59
59
  { name: "20261101011859_version-43-memory-project", version: 43, minCompatible: 34 },
60
60
  { name: "20261102050533_version-44-relationship-purpose", version: 44, minCompatible: 34 },
61
61
  { name: "20261102050534_version-44-suggested-links-move", version: 44, minCompatible: 34 },
62
+ { name: "20261102173102_version-45-finance", version: 45, minCompatible: 34 },
63
+ { name: "20261102173113_version-45-finance-rules", version: 45, minCompatible: 34 },
64
+ { name: "20261102185954_version-46-payment-facts", version: 46, minCompatible: 34 },
62
65
  ];
63
66
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
64
67
  const migrations = [];