@wirecat/cli-core 0.555.0 → 0.557.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 (68) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/cli/config-command.js +64 -2
  3. package/dist/cli/config-issues.d.ts +2 -0
  4. package/dist/cli/config-issues.js +11 -3
  5. package/dist/cli/config-repair.d.ts +26 -0
  6. package/dist/cli/config-repair.js +252 -0
  7. package/dist/cli/messenger/tasks-analysis-command.d.ts +8 -0
  8. package/dist/cli/messenger/tasks-analysis-command.js +184 -0
  9. package/dist/cli/messenger/tasks-command.js +11 -2
  10. package/dist/cli/steps.js +10 -0
  11. package/dist/domain/finance.d.ts +40 -0
  12. package/dist/domain/finance.js +39 -0
  13. package/dist/domain/money.d.ts +29 -0
  14. package/dist/domain/money.js +98 -0
  15. package/dist/domain/ref-summaries.js +2 -0
  16. package/dist/domain/references.d.ts +1 -1
  17. package/dist/domain/references.js +2 -0
  18. package/dist/entities/index.d.ts +64 -2
  19. package/dist/entities/index.js +5 -2
  20. package/dist/entities/task-results.d.ts +72 -0
  21. package/dist/entities/task-results.js +40 -1
  22. package/dist/errors/reasons.d.ts +1 -1
  23. package/dist/errors/reasons.js +24 -0
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.js +2 -0
  26. package/dist/mail/cli/index.d.ts +1 -0
  27. package/dist/mail/cli/index.js +3 -0
  28. package/dist/mail/cli/marks.d.ts +26 -0
  29. package/dist/mail/cli/marks.js +51 -0
  30. package/dist/mail/cli/review.js +70 -1
  31. package/dist/mail/index.d.ts +1 -0
  32. package/dist/mail/index.js +1 -0
  33. package/dist/mail/marks.d.ts +56 -0
  34. package/dist/mail/marks.js +178 -0
  35. package/dist/meetings/mcp/index.d.ts +15 -15
  36. package/dist/sends/permissions.js +1 -0
  37. package/dist/services/proposals/kinds/finance.d.ts +4 -0
  38. package/dist/services/proposals/kinds/finance.js +40 -0
  39. package/dist/services/proposals/registry.js +3 -0
  40. package/dist/services/task-analysis.d.ts +58 -0
  41. package/dist/services/task-analysis.js +125 -0
  42. package/dist/store/exports.d.ts +2 -2
  43. package/dist/store/index.d.ts +2 -1
  44. package/dist/store/sqlite/email-activity.d.ts +31 -0
  45. package/dist/store/sqlite/email-activity.js +38 -0
  46. package/dist/store/sqlite/emails.d.ts +2 -0
  47. package/dist/store/sqlite/emails.js +1 -0
  48. package/dist/store/sqlite/entity-types.d.ts +2 -0
  49. package/dist/store/sqlite/entity-types.js +2 -0
  50. package/dist/store/sqlite/finance.d.ts +199 -0
  51. package/dist/store/sqlite/finance.js +514 -0
  52. package/dist/store/sqlite/knowledge.js +3 -1
  53. package/dist/store/sqlite/manifest.js +2 -0
  54. package/dist/store/sqlite/migrations.generated.js +35 -0
  55. package/dist/store/sqlite/ref-summaries.js +33 -0
  56. package/dist/store/sqlite/schema.d.ts +1043 -0
  57. package/dist/store/sqlite/schema.js +98 -0
  58. package/dist/store/sqlite/task-analysis-marks.d.ts +7 -0
  59. package/dist/store/sqlite/task-analysis-marks.js +27 -0
  60. package/dist/store/sqlite/things.js +2 -0
  61. package/dist/store/store.d.ts +3 -0
  62. package/dist/store/store.js +5 -0
  63. package/dist/store/sync.d.ts +5 -1
  64. package/dist/store/sync.js +4 -0
  65. package/dist/store/types.d.ts +25 -0
  66. package/package.json +1 -1
  67. package/parity.json +29 -0
  68. package/skills/follow-ups/SKILL.md +22 -2
@@ -0,0 +1,514 @@
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, 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 MAX_LINES = 500;
34
+ const reasonOf = (metadata) => {
35
+ if (metadata == null)
36
+ return null;
37
+ try {
38
+ const { reason } = JSON.parse(String(metadata));
39
+ return typeof reason === "string" ? reason : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ };
45
+ /** Money settled by confirmed payments only: a proposed payment promises nothing yet. */
46
+ const PAID = "(SELECT coalesce(sum(a.amount), 0) FROM payment_allocations a JOIN payments p ON p.id = a.payment_id " +
47
+ "WHERE a.invoice_id = i.id AND p.review = 'confirmed' AND p.deleted_at IS NULL)";
48
+ const INVOICES = `SELECT i.*, ${PAID} AS paid, b.name AS author_name, b.kind AS author_kind FROM invoices i ` +
49
+ "LEFT JOIN bots b ON i.author_type = 'bot' AND b.id = i.author_id";
50
+ const PAYMENTS = "SELECT p.*, (SELECT coalesce(sum(amount), 0) FROM payment_allocations WHERE payment_id = p.id) AS allocated, " +
51
+ "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";
52
+ export const financeOperationsOver = (context) => {
53
+ const { database, now } = context;
54
+ const partyOf = (type, id) => {
55
+ if (type == null)
56
+ return null;
57
+ const { subject, subjectName, subjectIsOwner } = subjectOf(database, { subject_type: type, subject_id: id });
58
+ return subject === null ? null : { ref: subject, name: subjectName, owner: subjectIsOwner };
59
+ };
60
+ const partyThing = (reference, what) => {
61
+ if (reference === undefined)
62
+ return undefined;
63
+ const thing = requiredThing(database, reference, what);
64
+ if (thing.type !== "person" && thing.type !== "organization")
65
+ return refuse(`${what} is a person: or organization: reference, not ${reference}`, "finance.party");
66
+ return thing;
67
+ };
68
+ const reviewedOf = (row) => ({
69
+ review: row.review,
70
+ author: authorOf(row),
71
+ model: row.model == null ? null : String(row.model),
72
+ confirmedBy: row.confirmed_by_type == null
73
+ ? null
74
+ : { type: row.confirmed_by_type, id: String(row.confirmed_by_id) },
75
+ source: String(row.source),
76
+ reason: reasonOf(row.metadata),
77
+ ...projectOf(database, row),
78
+ createdAt: iso(row.created_at),
79
+ updatedAt: iso(row.updated_at),
80
+ });
81
+ const invoiceOf = (row) => {
82
+ const currency = String(row.currency);
83
+ const scale = scaleOf(currency);
84
+ const [total, paid] = [Number(row.total), Number(row.paid)];
85
+ const counterparty = partyOf(row.counterparty_type, row.counterparty_id);
86
+ const fields = {
87
+ id: Number(row.id),
88
+ number: row.number == null ? null : String(row.number),
89
+ direction: row.direction,
90
+ total,
91
+ currency,
92
+ counterpartyName: counterparty?.name ?? null,
93
+ dueDate: row.due_date == null ? null : String(row.due_date),
94
+ status: row.status,
95
+ review: row.review,
96
+ };
97
+ return {
98
+ id: String(row.id),
99
+ ref: `invoice:${row.id}`,
100
+ title: invoiceTitle(fields),
101
+ direction: fields.direction,
102
+ number: fields.number,
103
+ issuer: partyOf(row.issuer_type, row.issuer_id),
104
+ counterparty,
105
+ currency,
106
+ total: formatDecimal(total, scale),
107
+ paid: formatDecimal(paid, scale),
108
+ open: formatDecimal(Math.max(total - paid, 0), scale),
109
+ issueDate: row.issue_date == null ? null : String(row.issue_date),
110
+ dueDate: fields.dueDate,
111
+ status: fields.status,
112
+ ...reviewedOf(row),
113
+ };
114
+ };
115
+ const paymentOf = (row) => {
116
+ const currency = String(row.currency);
117
+ const scale = scaleOf(currency);
118
+ const [amount, allocated] = [Number(row.amount), Number(row.allocated)];
119
+ const direction = row.direction;
120
+ const payer = partyOf(row.payer_type, row.payer_id);
121
+ const payee = partyOf(row.payee_type, row.payee_id);
122
+ const date = String(row.payment_date);
123
+ return {
124
+ id: String(row.id),
125
+ ref: `payment:${row.id}`,
126
+ title: paymentEntryTitle({
127
+ direction,
128
+ amount,
129
+ currency,
130
+ partyName: (direction === "in" ? payer : payee)?.name ?? null,
131
+ date,
132
+ review: row.review,
133
+ }),
134
+ direction,
135
+ payer,
136
+ payee,
137
+ amount: formatDecimal(amount, scale),
138
+ currency,
139
+ date,
140
+ reference: row.reference == null ? null : String(row.reference),
141
+ allocated: formatDecimal(allocated, scale),
142
+ unallocated: formatDecimal(amount - allocated, scale),
143
+ ...reviewedOf(row),
144
+ };
145
+ };
146
+ const allocationsWhere = (column, id) => database
147
+ .prepare("SELECT a.*, p.currency FROM payment_allocations a JOIN payments p ON p.id = a.payment_id " +
148
+ `WHERE a.${column} = ? ORDER BY a.id`)
149
+ .all(id)
150
+ .map((row) => ({
151
+ payment: `payment:${row.payment_id}`,
152
+ invoice: `invoice:${row.invoice_id}`,
153
+ amount: formatDecimal(Number(row.amount), scaleOf(String(row.currency))),
154
+ currency: String(row.currency),
155
+ createdAt: iso(row.created_at),
156
+ }));
157
+ const rowOf = (reference, type) => {
158
+ const thing = requiredThing(database, reference.includes(":") ? reference : `${type}:${reference}`, `the ${type}`);
159
+ if (thing.type !== type)
160
+ return refuse(`${reference} is not an ${type}`, "finance.ref");
161
+ return database.prepare(`${type === "invoice" ? INVOICES : PAYMENTS} WHERE ${type[0]}.id = ?`).get(thing.id);
162
+ };
163
+ const invoiceDetail = (reference) => {
164
+ const row = rowOf(reference, "invoice");
165
+ const scale = scaleOf(String(row.currency));
166
+ const lines = database
167
+ .prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY position")
168
+ .all(Number(row.id))
169
+ .map((line) => ({
170
+ position: Number(line.position),
171
+ description: String(line.description),
172
+ quantity: String(line.quantity),
173
+ unitPrice: line.unit_price == null ? null : formatDecimal(Number(line.unit_price), scale),
174
+ amount: formatDecimal(Number(line.amount), scale),
175
+ }));
176
+ return { ...invoiceOf(row), lines, allocations: allocationsWhere("invoice_id", Number(row.id)) };
177
+ };
178
+ const paymentDetail = (reference) => {
179
+ const row = rowOf(reference, "payment");
180
+ return { ...paymentOf(row), allocations: allocationsWhere("payment_id", Number(row.id)) };
181
+ };
182
+ const settle = (invoiceId) => {
183
+ const row = database
184
+ .prepare(`SELECT i.status, i.total, ${PAID} AS paid FROM invoices i WHERE i.id = ?`)
185
+ .get(invoiceId);
186
+ if (!row)
187
+ return;
188
+ const status = settledStatus(row.status, Number(row.total), Number(row.paid));
189
+ database
190
+ .prepare("UPDATE invoices SET status = ?, updated_at = ? WHERE id = ? AND status <> ?")
191
+ .run(status, now(), invoiceId, status);
192
+ };
193
+ const allocateRow = (payment, reference, units, actor) => {
194
+ const invoice = rowOf(reference, "invoice");
195
+ const [paymentRef, invoiceRef] = [`payment:${payment.id}`, `invoice:${invoice.id}`];
196
+ if (invoice.deleted_at != null || invoice.review === "rejected")
197
+ refuse(`${invoiceRef} is ${invoice.review === "rejected" ? "rejected" : "deleted"}`, "finance.allocate");
198
+ if (invoice.status === "draft" || invoice.status === "void")
199
+ refuse(`${invoiceRef} is ${invoice.status}; only an issued invoice is paid`, "finance.allocate");
200
+ const currency = String(payment.currency);
201
+ if (invoice.currency !== currency)
202
+ refuse(`${paymentRef} is in ${currency} and ${invoiceRef} in ${String(invoice.currency)}; the store converts nothing`, "finance.currency");
203
+ const fromPayment = Number(database
204
+ .prepare("SELECT coalesce(sum(amount), 0) AS n FROM payment_allocations WHERE payment_id = ?")
205
+ .get(Number(payment.id))?.n);
206
+ if (fromPayment + units > Number(payment.amount))
207
+ refuse(`${paymentRef} is ${moneyLabel(Number(payment.amount), currency)} and ` +
208
+ `${moneyLabel(fromPayment, currency)} of it is already allocated; ${moneyLabel(units, currency)} more is too much`, "finance.over-payment");
209
+ const toInvoice = Number(database
210
+ .prepare("SELECT coalesce(sum(a.amount), 0) AS n FROM payment_allocations a JOIN payments p ON p.id = a.payment_id " +
211
+ "WHERE a.invoice_id = ? AND ((p.review = 'confirmed' AND p.deleted_at IS NULL) OR a.payment_id = ?)")
212
+ .get(Number(invoice.id), Number(payment.id))?.n);
213
+ if (toInvoice + units > Number(invoice.total))
214
+ refuse(`${invoiceRef} totals ${moneyLabel(Number(invoice.total), currency)} and ` +
215
+ `${moneyLabel(toInvoice, currency)} is already allocated to it; ${moneyLabel(units, currency)} more is too much`, "finance.over-invoice");
216
+ const at = now();
217
+ database
218
+ .prepare("INSERT INTO payment_allocations (payment_id, invoice_id, amount, author_type, author_id, created_at, updated_at) " +
219
+ "VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (payment_id, invoice_id) " +
220
+ "DO UPDATE SET amount = amount + excluded.amount, updated_at = excluded.updated_at")
221
+ .run(Number(payment.id), Number(invoice.id), units, actor.type, actor.id, at, at);
222
+ return Number(invoice.id);
223
+ };
224
+ const written = (input) => {
225
+ const owner = input.by === "owner";
226
+ const { actor, model } = authorActor(context, input.by);
227
+ const source = owner ? "owner" : (text(input.source, "a source", 80) ?? "agent");
228
+ const reason = owner ? null : text(input.reason, "a reason", 2000);
229
+ return {
230
+ owner,
231
+ actor,
232
+ model,
233
+ source,
234
+ review: owner ? "confirmed" : "proposed",
235
+ confirmedType: owner ? actor.type : null,
236
+ confirmedId: owner ? actor.id : null,
237
+ metadata: reason === null ? null : JSON.stringify({ reason }),
238
+ project: input.project === undefined ? null : projectId(database, input.project),
239
+ };
240
+ };
241
+ const ownerOnly = (by, what) => {
242
+ if (by !== "owner")
243
+ refuse(`only the owner ${what}`, "finance.owner-only", "permission_error");
244
+ };
245
+ const keepDecision = (table, id, by, reason) => {
246
+ const why = [by === "owner" ? undefined : by.reason.trim(), reason?.trim() || undefined].filter(Boolean).join(": ");
247
+ if (by === "owner" && !why)
248
+ return;
249
+ const decision = by === "owner" ? { by: "owner", reason: why } : { by: "agent", bot: by.bot, reason: why };
250
+ database
251
+ .prepare(`UPDATE ${table} SET metadata = json_set(coalesce(metadata, '{}'), '$.decision', json(?)) WHERE id = ?`)
252
+ .run(JSON.stringify(decision), id);
253
+ };
254
+ /** A proposed payment's allocations count once confirmed; by then other confirmed payments may cover the invoice. */
255
+ const coveredElsewhere = (paymentId) => {
256
+ const over = database
257
+ .prepare("SELECT a.invoice_id, a.amount, i.total, i.currency, (SELECT coalesce(sum(o.amount), 0) FROM payment_allocations o " +
258
+ "JOIN payments p ON p.id = o.payment_id WHERE o.invoice_id = a.invoice_id AND o.payment_id <> a.payment_id " +
259
+ "AND p.review = 'confirmed' AND p.deleted_at IS NULL) AS others FROM payment_allocations a " +
260
+ "JOIN invoices i ON i.id = a.invoice_id WHERE a.payment_id = ? AND a.amount + others > i.total LIMIT 1")
261
+ .get(paymentId);
262
+ if (!over)
263
+ return;
264
+ const currency = String(over.currency);
265
+ refuse(`invoice:${over.invoice_id} is already covered by other payments: ${moneyLabel(Number(over.others), currency)} ` +
266
+ `of ${moneyLabel(Number(over.total), currency)}; payment:${paymentId} would add ` +
267
+ `${moneyLabel(Number(over.amount), currency)}, so it stays proposed`, "finance.over-invoice");
268
+ };
269
+ const decide = (reference, by, reason, confirm) => {
270
+ const thing = requiredThing(database, reference, "the proposal");
271
+ if (thing.type !== "invoice" && thing.type !== "payment")
272
+ return refuse(`${reference} is not an invoice: or payment:`, "finance.ref");
273
+ const type = thing.type;
274
+ const table = type === "invoice" ? "invoices" : "payments";
275
+ const row = rowOf(reference, type);
276
+ const ref = `${type}:${row.id}`;
277
+ assertMayDecide({ id: ref, proposedBy: proposerOf(database, row.author_type, row.author_id), ownerOnly: true }, by, context.decisions);
278
+ if (row.review !== "proposed")
279
+ refuse(`${ref} is already ${String(row.review)}`, "proposal.wrong-state");
280
+ const id = Number(row.id);
281
+ write(database, () => {
282
+ const at = now();
283
+ if (confirm && type === "payment")
284
+ coveredElsewhere(id);
285
+ if (confirm) {
286
+ const { actor } = authorActor(context, deciderAuthor(by));
287
+ database
288
+ .prepare(`UPDATE ${table} SET review = 'confirmed', confirmed_by_type = ?, confirmed_by_id = ?, updated_at = ? WHERE id = ?`)
289
+ .run(actor.type, actor.id, at, id);
290
+ }
291
+ else
292
+ database.prepare(`UPDATE ${table} SET review = 'rejected', updated_at = ? WHERE id = ?`).run(at, id);
293
+ keepDecision(table, id, by, reason);
294
+ const touched = type === "invoice"
295
+ ? [id]
296
+ : database
297
+ .prepare("SELECT invoice_id FROM payment_allocations WHERE payment_id = ?")
298
+ .all(id)
299
+ .map((one) => Number(one.invoice_id));
300
+ if (!confirm)
301
+ database
302
+ .prepare(`DELETE FROM payment_allocations WHERE ${type === "invoice" ? "invoice_id" : "payment_id"} = ?`)
303
+ .run(id);
304
+ for (const invoice of touched)
305
+ settle(invoice);
306
+ });
307
+ return type === "invoice" ? invoiceDetail(ref) : paymentDetail(ref);
308
+ };
309
+ const limitOf = (limit) => Math.min(Math.max(limit ?? 100, 1), 500);
310
+ return {
311
+ addInvoice: (input) => {
312
+ const direction = oneOf(input.direction, INVOICE_DIRECTIONS, "an invoice's direction");
313
+ const currency = currencyOf(input.currency);
314
+ if (!Array.isArray(input.lines) || input.lines.length === 0 || input.lines.length > MAX_LINES)
315
+ refuse(`an invoice has 1–${MAX_LINES} lines`, "finance.lines");
316
+ const lines = input.lines.map((line, at) => {
317
+ const what = `line ${at + 1}`;
318
+ const quantity = quantityOf(line.quantity ?? "1");
319
+ const unitPrice = line.unitPrice === undefined ? null : minorUnits(line.unitPrice, currency, `${what}'s unit price`);
320
+ const computed = unitPrice === null ? null : lineUnits(quantity.thousandths, unitPrice);
321
+ const amount = line.amount === undefined ? computed : minorUnits(line.amount, currency, `${what}'s amount`);
322
+ if (amount === null)
323
+ return refuse(`${what} takes a unit price, an amount or both`, "finance.lines");
324
+ if (computed !== null && computed !== amount)
325
+ refuse(`${what}: ${quantity.text} × ${moneyLabel(unitPrice, currency)} is ` +
326
+ `${moneyLabel(computed, currency)}, not ${moneyLabel(amount, currency)}`, "finance.line-amount");
327
+ return {
328
+ description: text(line.description, `${what}'s description`, 500) ??
329
+ refuse(`${what} needs a description`, "finance.lines"),
330
+ quantity: quantity.text,
331
+ unitPrice: unitPrice ?? (quantity.thousandths === 1000n ? amount : null),
332
+ amount,
333
+ };
334
+ });
335
+ const total = lines.reduce((sum, line) => sum + line.amount, 0);
336
+ if (!Number.isSafeInteger(total))
337
+ refuse("an invoice's total is too large", "finance.total");
338
+ if (total <= 0)
339
+ refuse("an invoice totals more than 0", "finance.total");
340
+ if (input.total !== undefined) {
341
+ const printed = minorUnits(input.total, currency, "an invoice's total");
342
+ if (printed !== total)
343
+ refuse(`the invoice's total ${moneyLabel(printed, currency)} is not the sum of its lines, ${moneyLabel(total, currency)}`, "finance.total");
344
+ }
345
+ const number = text(input.number, "an invoice's number", 80);
346
+ const issueDate = isoDate(input.issueDate, "an invoice's issue date");
347
+ const dueDate = isoDate(input.dueDate, "an invoice's due date");
348
+ const id = write(database, () => {
349
+ const issuer = partyThing(input.issuer, "the issuer");
350
+ const counterparty = partyThing(input.counterparty, "the counterparty") ??
351
+ refuse("an invoice names its counterparty, a person: or organization:", "finance.party");
352
+ const by = written(input);
353
+ const at = now();
354
+ const made = Number(database
355
+ .prepare("INSERT INTO invoices (direction, number, issuer_type, issuer_id, counterparty_type, counterparty_id, project_id, " +
356
+ "currency, total, issue_date, due_date, status, review, author_type, author_id, model, confirmed_by_type, " +
357
+ "confirmed_by_id, source, metadata, created_at, updated_at) " +
358
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
359
+ .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);
360
+ const insert = database.prepare("INSERT INTO invoice_lines (invoice_id, position, description, quantity, unit_price, amount, created_at, updated_at) " +
361
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
362
+ for (const [index, line] of lines.entries())
363
+ insert.run(made, index + 1, line.description, line.quantity, line.unitPrice, line.amount, at, at);
364
+ return made;
365
+ });
366
+ return invoiceDetail(`invoice:${id}`);
367
+ },
368
+ invoice: (reference) => invoiceDetail(reference),
369
+ invoices: (filter = {}) => {
370
+ const project = filter.project === undefined ? null : projectId(database, filter.project);
371
+ const counterparty = partyThing(filter.counterparty, "the counterparty");
372
+ const by = proposedBySql("i.author_type", "i.author_id", filter.proposedBy);
373
+ const status = filter.status === undefined ? null : oneOf(filter.status, INVOICE_STATUSES, "a status");
374
+ const review = filter.review === undefined ? null : oneOf(filter.review, FINANCE_REVIEWS, "a review");
375
+ return database
376
+ .prepare(`${INVOICES} WHERE i.deleted_at IS NULL AND (? IS NULL OR i.direction = ?) AND (? IS NULL OR i.status = ?) ` +
377
+ "AND (? IS NULL OR i.review = ?) AND (? IS NULL OR i.project_id = ?) " +
378
+ "AND (? IS NULL OR (i.counterparty_type = ? AND i.counterparty_id = ?)) " +
379
+ `${filter.open ? "AND i.status IN ('issued', 'partly_paid') " : ""}AND ${by.sql} ` +
380
+ "ORDER BY i.created_at DESC, i.id DESC LIMIT ?")
381
+ .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))
382
+ .map(invoiceOf);
383
+ },
384
+ issueInvoice: (reference, by) => {
385
+ ownerOnly(by, "issues an invoice");
386
+ const row = rowOf(reference, "invoice");
387
+ if (row.status !== "draft")
388
+ refuse(`invoice:${row.id} is ${String(row.status)}, not a draft`, "finance.status");
389
+ write(database, () => {
390
+ database
391
+ .prepare("UPDATE invoices SET status = 'issued', updated_at = ? WHERE id = ?")
392
+ .run(now(), Number(row.id));
393
+ settle(Number(row.id));
394
+ });
395
+ return invoiceDetail(`invoice:${row.id}`);
396
+ },
397
+ voidInvoice: (reference, by) => {
398
+ ownerOnly(by, "voids an invoice");
399
+ const row = rowOf(reference, "invoice");
400
+ if (row.status === "void")
401
+ refuse(`invoice:${row.id} is already void`, "finance.status");
402
+ const allocated = database
403
+ .prepare("SELECT 1 FROM payment_allocations a JOIN payments p ON p.id = a.payment_id " +
404
+ "WHERE a.invoice_id = ? AND p.review = 'confirmed' AND p.deleted_at IS NULL LIMIT 1")
405
+ .get(Number(row.id));
406
+ if (allocated)
407
+ refuse(`payments are allocated to invoice:${row.id}; a paid invoice is not voided`, "finance.status");
408
+ write(database, () => {
409
+ database
410
+ .prepare("DELETE FROM payment_allocations WHERE invoice_id = ? " +
411
+ "AND payment_id IN (SELECT id FROM payments WHERE review = 'proposed')")
412
+ .run(Number(row.id));
413
+ database.prepare("UPDATE invoices SET status = 'void', updated_at = ? WHERE id = ?").run(now(), Number(row.id));
414
+ });
415
+ return invoiceDetail(`invoice:${row.id}`);
416
+ },
417
+ addPayment: (input) => {
418
+ const direction = oneOf(input.direction, MONEY_DIRECTIONS, "a payment's direction");
419
+ const currency = currencyOf(input.currency);
420
+ const amount = minorUnits(input.amount, currency, "a payment's amount");
421
+ if (amount <= 0)
422
+ refuse("a payment's amount is more than 0", "money.amount");
423
+ const date = isoDate(input.date, "a payment's date") ?? refuse("a payment has a date", "finance.field");
424
+ const reference = text(input.reference, "a payment's reference", 500);
425
+ const allocations = (input.allocations ?? []).map(({ invoice, amount: share }) => ({
426
+ invoice,
427
+ units: minorUnits(share, currency, `the amount allocated to ${invoice}`),
428
+ }));
429
+ const id = write(database, () => {
430
+ const payer = partyThing(input.payer, "the payer");
431
+ const payee = partyThing(input.payee, "the payee");
432
+ const by = written(input);
433
+ const at = now();
434
+ const made = database
435
+ .prepare("INSERT INTO payments (direction, payer_type, payer_id, payee_type, payee_id, amount, currency, payment_date, " +
436
+ "reference, project_id, review, author_type, author_id, model, confirmed_by_type, confirmed_by_id, source, " +
437
+ "metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *")
438
+ .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);
439
+ const touched = allocations.map(({ invoice, units }) => allocateRow(made, invoice, units, by.actor));
440
+ for (const invoice of touched)
441
+ settle(invoice);
442
+ return Number(made.id);
443
+ });
444
+ return paymentDetail(`payment:${id}`);
445
+ },
446
+ payment: (reference) => paymentDetail(reference),
447
+ payments: (filter = {}) => {
448
+ const project = filter.project === undefined ? null : projectId(database, filter.project);
449
+ const party = partyThing(filter.party, "the party");
450
+ const by = proposedBySql("p.author_type", "p.author_id", filter.proposedBy);
451
+ const review = filter.review === undefined ? null : oneOf(filter.review, FINANCE_REVIEWS, "a review");
452
+ return database
453
+ .prepare(`${PAYMENTS} WHERE p.deleted_at IS NULL AND (? IS NULL OR p.direction = ?) AND (? IS NULL OR p.review = ?) ` +
454
+ "AND (? IS NULL OR p.project_id = ?) AND (? IS NULL OR (p.payer_type = ? AND p.payer_id = ?) " +
455
+ `OR (p.payee_type = ? AND p.payee_id = ?)) AND ${by.sql} ORDER BY p.created_at DESC, p.id DESC LIMIT ?`)
456
+ .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))
457
+ .map(paymentOf);
458
+ },
459
+ allocate: ({ payment, invoice, amount, by }) => {
460
+ const row = rowOf(payment, "payment");
461
+ const ref = `payment:${row.id}`;
462
+ if (by !== "owner") {
463
+ const own = row.author_type === "bot" && row.author_name != null && botKey(String(row.author_name)) === botKey(by.bot);
464
+ if (row.review !== "proposed" || !own)
465
+ refuse(`only the owner allocates ${ref}; a bot allocates only from a payment it proposed that is still proposed`, "finance.owner-only", "permission_error");
466
+ }
467
+ if (row.review === "rejected" || row.deleted_at != null)
468
+ refuse(`${ref} is ${String(row.review)}`, "finance.allocate");
469
+ const currency = String(row.currency);
470
+ const units = minorUnits(amount, currency, "an allocation");
471
+ if (units <= 0)
472
+ refuse("an allocation is more than 0", "money.amount");
473
+ const invoiceId = write(database, () => {
474
+ const { actor } = authorActor(context, by);
475
+ const made = allocateRow(row, invoice, units, actor);
476
+ settle(made);
477
+ return made;
478
+ });
479
+ const found = allocationsWhere("payment_id", Number(row.id)).find((one) => one.invoice === `invoice:${invoiceId}`);
480
+ return found;
481
+ },
482
+ confirm: (reference, by, reason) => decide(reference, by, reason, true),
483
+ reject: (reference, by, reason) => decide(reference, by, reason, false),
484
+ openTotals: (filter = {}) => {
485
+ const project = filter.project === undefined ? null : projectId(database, filter.project);
486
+ const rows = database
487
+ .prepare(`SELECT i.direction, i.currency, sum(i.total - ${PAID}) AS open, count(*) AS n FROM invoices i ` +
488
+ "WHERE i.review = 'confirmed' AND i.deleted_at IS NULL AND i.status IN ('issued', 'partly_paid') " +
489
+ "AND (? IS NULL OR i.project_id = ?) GROUP BY i.direction, i.currency ORDER BY i.currency")
490
+ .all(project, project);
491
+ const totals = (direction) => rows
492
+ .filter((row) => row.direction === direction)
493
+ .map((row) => ({ ...moneyOf(Number(row.open), String(row.currency)), invoices: Number(row.n) }));
494
+ return { receivable: totals("receivable"), payable: totals("payable") };
495
+ },
496
+ };
497
+ };
498
+ export const financeStoreOver = (context) => {
499
+ const operations = financeOperationsOver(context);
500
+ return {
501
+ addInvoice: async (input) => operations.addInvoice(input),
502
+ invoice: async (reference) => operations.invoice(reference),
503
+ invoices: async (filter) => operations.invoices(filter),
504
+ issueInvoice: async (reference, by) => operations.issueInvoice(reference, by),
505
+ voidInvoice: async (reference, by) => operations.voidInvoice(reference, by),
506
+ addPayment: async (input) => operations.addPayment(input),
507
+ payment: async (reference) => operations.payment(reference),
508
+ payments: async (filter) => operations.payments(filter),
509
+ allocate: async (input) => operations.allocate(input),
510
+ confirm: async (reference, by, reason) => operations.confirm(reference, by, reason),
511
+ reject: async (reference, by, reason) => operations.reject(reference, by, reason),
512
+ openTotals: async (filter) => operations.openTotals(filter),
513
+ };
514
+ };
@@ -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,8 @@ 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 },
62
64
  ];
63
65
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
64
66
  const migrations = [];
@@ -889,5 +889,40 @@ export const GENERATED = [
889
889
  "statements": [
890
890
  "-- A suggestion's references (chat:, msg:) need the store's own encoding, which SQL cannot write, so the next writable\n-- open moves the unconfirmed agent and rule links into `proposed_actions` and deletes this key (moveSuggestedLinks).\nINSERT OR REPLACE INTO store_settings (key, value, at) VALUES ('suggested_links_to_move', '1', unixepoch() * 1000);"
891
891
  ]
892
+ },
893
+ {
894
+ "name": "20261102173102_version-45-finance",
895
+ "statements": [
896
+ "CREATE TABLE `invoice_lines` (\n\t`id` integer PRIMARY KEY,\n\t`invoice_id` integer NOT NULL,\n\t`position` integer NOT NULL,\n\t`description` text NOT NULL,\n\t`quantity` text NOT NULL,\n\t`unit_price` integer,\n\t`amount` integer NOT NULL,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\tCONSTRAINT `fk_invoice_lines_invoice_id_invoices_id_fk` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`),\n\tCONSTRAINT `invoice_lines_invoice_id_position_unique` UNIQUE(`invoice_id`,`position`)\n);",
897
+ "CREATE TABLE `invoices` (\n\t`id` integer PRIMARY KEY,\n\t`direction` text NOT NULL,\n\t`number` text,\n\t`issuer_type` text,\n\t`issuer_id` integer,\n\t`counterparty_type` text NOT NULL,\n\t`counterparty_id` integer NOT NULL,\n\t`project_id` integer,\n\t`currency` text NOT NULL,\n\t`total` integer NOT NULL,\n\t`issue_date` text,\n\t`due_date` text,\n\t`status` text NOT NULL,\n\t`review` text NOT NULL,\n\t`author_type` text NOT NULL,\n\t`author_id` integer NOT NULL,\n\t`model` text,\n\t`confirmed_by_type` text,\n\t`confirmed_by_id` integer,\n\t`source` text NOT NULL,\n\t`metadata` text,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\t`deleted_at` integer,\n\tCONSTRAINT `fk_invoices_project_id_projects_id_fk` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`)\n);",
898
+ "CREATE TABLE `payment_allocations` (\n\t`id` integer PRIMARY KEY,\n\t`payment_id` integer NOT NULL,\n\t`invoice_id` integer NOT NULL,\n\t`amount` integer NOT NULL,\n\t`author_type` text NOT NULL,\n\t`author_id` integer NOT NULL,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\tCONSTRAINT `fk_payment_allocations_payment_id_payments_id_fk` FOREIGN KEY (`payment_id`) REFERENCES `payments`(`id`),\n\tCONSTRAINT `fk_payment_allocations_invoice_id_invoices_id_fk` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`),\n\tCONSTRAINT `payment_allocations_payment_id_invoice_id_unique` UNIQUE(`payment_id`,`invoice_id`)\n);",
899
+ "CREATE TABLE `payments` (\n\t`id` integer PRIMARY KEY,\n\t`direction` text NOT NULL,\n\t`payer_type` text,\n\t`payer_id` integer,\n\t`payee_type` text,\n\t`payee_id` integer,\n\t`amount` integer NOT NULL,\n\t`currency` text NOT NULL,\n\t`payment_date` text NOT NULL,\n\t`reference` text,\n\t`project_id` integer,\n\t`review` text NOT NULL,\n\t`author_type` text NOT NULL,\n\t`author_id` integer NOT NULL,\n\t`model` text,\n\t`confirmed_by_type` text,\n\t`confirmed_by_id` integer,\n\t`source` text NOT NULL,\n\t`metadata` text,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\t`deleted_at` integer,\n\tCONSTRAINT `fk_payments_project_id_projects_id_fk` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`)\n);",
900
+ "CREATE INDEX `invoices_by_issuer_type_issuer_id` ON `invoices` (`issuer_type`,`issuer_id`);",
901
+ "CREATE INDEX `invoices_by_counterparty_type_counterparty_id` ON `invoices` (`counterparty_type`,`counterparty_id`);",
902
+ "CREATE INDEX `invoices_by_project_id` ON `invoices` (`project_id`);",
903
+ "CREATE INDEX `invoices_by_author_type_author_id` ON `invoices` (`author_type`,`author_id`);",
904
+ "CREATE INDEX `invoices_by_confirmed_by_type_confirmed_by_id` ON `invoices` (`confirmed_by_type`,`confirmed_by_id`);",
905
+ "CREATE INDEX `payment_allocations_by_invoice_id` ON `payment_allocations` (`invoice_id`);",
906
+ "CREATE INDEX `payment_allocations_by_author_type_author_id` ON `payment_allocations` (`author_type`,`author_id`);",
907
+ "CREATE INDEX `payments_by_payer_type_payer_id` ON `payments` (`payer_type`,`payer_id`);",
908
+ "CREATE INDEX `payments_by_payee_type_payee_id` ON `payments` (`payee_type`,`payee_id`);",
909
+ "CREATE INDEX `payments_by_project_id` ON `payments` (`project_id`);",
910
+ "CREATE INDEX `payments_by_author_type_author_id` ON `payments` (`author_type`,`author_id`);",
911
+ "CREATE INDEX `payments_by_confirmed_by_type_confirmed_by_id` ON `payments` (`confirmed_by_type`,`confirmed_by_id`);"
912
+ ]
913
+ },
914
+ {
915
+ "name": "20261102173113_version-45-finance-rules",
916
+ "statements": [
917
+ "-- The store's code checks these first and says why; the triggers keep a stray write from another build or a hand\n-- edit from storing money that does not add up. An invoice's limit counts confirmed payments only, so an agent's\n-- pending proposal never blocks the owner; confirming a payment checks the limit again.\nCREATE TRIGGER invoices_valid_bi BEFORE INSERT ON invoices\n WHEN new.direction NOT IN ('receivable', 'payable') OR new.total < 0\n OR new.status NOT IN ('draft', 'issued', 'partly_paid', 'paid', 'void')\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected') BEGIN\n SELECT RAISE(ABORT, 'invoices: direction receivable or payable, a total of 0 or more, a known status and review');\nEND;",
918
+ "CREATE TRIGGER invoices_valid_bu BEFORE UPDATE ON invoices\n WHEN new.direction NOT IN ('receivable', 'payable') OR new.total < 0\n OR new.status NOT IN ('draft', 'issued', 'partly_paid', 'paid', 'void')\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected')\n OR (new.currency IS NOT old.currency AND EXISTS (SELECT 1 FROM payment_allocations WHERE invoice_id = new.id))\n OR new.total < (SELECT coalesce(sum(a.amount), 0) FROM payment_allocations a JOIN payments p ON p.id = a.payment_id\n WHERE a.invoice_id = new.id AND p.review = 'confirmed' AND p.deleted_at IS NULL) BEGIN\n SELECT RAISE(ABORT, 'invoices: the total stays at or above what is allocated to it, in the same currency');\nEND;",
919
+ "CREATE TRIGGER invoice_lines_valid_bi BEFORE INSERT ON invoice_lines\n WHEN new.amount < 0 OR new.unit_price < 0 BEGIN\n SELECT RAISE(ABORT, 'invoice_lines: an amount and a unit price of 0 or more');\nEND;",
920
+ "CREATE TRIGGER invoice_lines_valid_bu BEFORE UPDATE ON invoice_lines\n WHEN new.amount < 0 OR new.unit_price < 0 BEGIN\n SELECT RAISE(ABORT, 'invoice_lines: an amount and a unit price of 0 or more');\nEND;",
921
+ "CREATE TRIGGER payments_valid_bi BEFORE INSERT ON payments\n WHEN new.direction NOT IN ('in', 'out') OR new.amount <= 0\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected') BEGIN\n SELECT RAISE(ABORT, 'payments: direction in or out, an amount above 0 and a known review');\nEND;",
922
+ "CREATE TRIGGER payments_valid_bu BEFORE UPDATE ON payments\n WHEN new.direction NOT IN ('in', 'out') OR new.amount <= 0\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected')\n OR (new.currency IS NOT old.currency AND EXISTS (SELECT 1 FROM payment_allocations WHERE payment_id = new.id))\n OR new.amount < (SELECT coalesce(sum(amount), 0) FROM payment_allocations WHERE payment_id = new.id) BEGIN\n SELECT RAISE(ABORT, 'payments: the amount stays at or above what is allocated from it, in the same currency');\nEND;",
923
+ "CREATE TRIGGER payments_confirm_bu BEFORE UPDATE OF review ON payments\n WHEN new.review = 'confirmed' AND old.review IS NOT 'confirmed' AND EXISTS (\n SELECT 1 FROM payment_allocations a JOIN invoices i ON i.id = a.invoice_id WHERE a.payment_id = new.id\n AND a.amount + (SELECT coalesce(sum(o.amount), 0) FROM payment_allocations o JOIN payments p ON p.id = o.payment_id\n WHERE o.invoice_id = a.invoice_id AND o.payment_id <> new.id AND p.review = 'confirmed' AND p.deleted_at IS NULL)\n > i.total) BEGIN\n SELECT RAISE(ABORT, 'payments: confirming it would settle more than an invoice''s total');\nEND;",
924
+ "CREATE TRIGGER payment_allocations_valid_bi BEFORE INSERT ON payment_allocations\n WHEN new.amount <= 0\n OR (SELECT currency FROM payments WHERE id = new.payment_id)\n IS NOT (SELECT currency FROM invoices WHERE id = new.invoice_id)\n OR (SELECT coalesce(sum(amount), 0) FROM payment_allocations WHERE payment_id = new.payment_id) + new.amount\n > (SELECT amount FROM payments WHERE id = new.payment_id)\n OR (SELECT coalesce(sum(a.amount), 0) FROM payment_allocations a JOIN payments p ON p.id = a.payment_id\n WHERE a.invoice_id = new.invoice_id\n AND ((p.review = 'confirmed' AND p.deleted_at IS NULL) OR a.payment_id = new.payment_id)) + new.amount\n > (SELECT total FROM invoices WHERE id = new.invoice_id) BEGIN\n SELECT RAISE(ABORT, 'payment_allocations: above 0, one currency, within the payment and within the invoice');\nEND;",
925
+ "CREATE TRIGGER payment_allocations_valid_bu BEFORE UPDATE ON payment_allocations\n WHEN new.amount <= 0 OR new.payment_id IS NOT old.payment_id OR new.invoice_id IS NOT old.invoice_id\n OR (SELECT coalesce(sum(amount), 0) FROM payment_allocations WHERE payment_id = new.payment_id AND id <> old.id)\n + new.amount > (SELECT amount FROM payments WHERE id = new.payment_id)\n OR (SELECT coalesce(sum(a.amount), 0) FROM payment_allocations a JOIN payments p ON p.id = a.payment_id\n WHERE a.invoice_id = new.invoice_id AND a.id <> old.id\n AND ((p.review = 'confirmed' AND p.deleted_at IS NULL) OR a.payment_id = new.payment_id)) + new.amount\n > (SELECT total FROM invoices WHERE id = new.invoice_id) BEGIN\n SELECT RAISE(ABORT, 'payment_allocations: above 0, within the payment and within the invoice, sides unchanged');\nEND;"
926
+ ]
892
927
  }
893
928
  ];
@@ -1,3 +1,4 @@
1
+ import { invoiceLine, paymentLine } from "../../domain/finance.js";
1
2
  import { filesAccountSql, providerLabel, providerNamed } from "../../domain/providers.js";
2
3
  import { oneLine } from "../../domain/ref-summaries.js";
3
4
  import { formatReference, parseReference } from "../../domain/references.js";
@@ -181,6 +182,23 @@ const READERS = {
181
182
  body: bodyOr(body, "x.statement"),
182
183
  })),
183
184
  bot: byId("bot", "bots", () => ({ title: "x.name" })),
185
+ // Money is formatted with its currency's scale in JS, so SQL hands `titleOf` the fields as JSON.
186
+ invoice: byId("invoice", "invoices", () => ({
187
+ title: "json_object('id', x.id, 'number', x.number, 'direction', x.direction, 'total', x.total, " +
188
+ `'currency', x.currency, 'counterpartyName', ${subjectName("x.counterparty_type", "x.counterparty_id")}, ` +
189
+ "'dueDate', x.due_date, 'status', x.status, 'review', x.review)",
190
+ holder: "(SELECT name FROM projects WHERE id = x.project_id)",
191
+ at: "x.created_at",
192
+ deleted: "x.deleted_at",
193
+ })),
194
+ payment: byId("payment", "payments", () => ({
195
+ title: "json_object('direction', x.direction, 'amount', x.amount, 'currency', x.currency, 'partyName', " +
196
+ `CASE x.direction WHEN 'in' THEN ${subjectName("x.payer_type", "x.payer_id")} ` +
197
+ `ELSE ${subjectName("x.payee_type", "x.payee_id")} END, 'date', x.payment_date, 'review', x.review)`,
198
+ holder: "(SELECT name FROM projects WHERE id = x.project_id)",
199
+ at: "x.created_at",
200
+ deleted: "x.deleted_at",
201
+ })),
184
202
  };
185
203
  /** Mail saved before the mail tables existed is still messages, so a miss there is read again as a message. */
186
204
  const FALLBACK = { email: "message", email_thread: "chat" };
@@ -262,6 +280,17 @@ const refOfAccount = (reference) => reference.type === "message" || reference.ty
262
280
  : reference.type === "contact"
263
281
  ? { provider: reference.provider, account: null }
264
282
  : { provider: null, account: null };
283
+ /** A summary never throws: fields that no longer parse, after `flat` made them one line, read as no title. */
284
+ const fieldsOf = (json, line) => {
285
+ if (json === null)
286
+ return null;
287
+ try {
288
+ return line(JSON.parse(json));
289
+ }
290
+ catch {
291
+ return null;
292
+ }
293
+ };
265
294
  const titleOf = (kind, reference, found) => {
266
295
  switch (kind) {
267
296
  case "message": {
@@ -271,6 +300,10 @@ const titleOf = (kind, reference, found) => {
271
300
  case "memory":
272
301
  case "fact":
273
302
  return cut(found.title, MEMORY_TITLE_CHARS);
303
+ case "invoice":
304
+ return fieldsOf(found.title, invoiceLine);
305
+ case "payment":
306
+ return fieldsOf(found.title, paymentLine);
274
307
  case "folder":
275
308
  return reference.type === "folder" && reference.path !== null && found.title !== null
276
309
  ? `${found.title}/${reference.path}`