@wirecat/cli-core 0.557.0 → 0.559.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 (55) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/cli/index.d.ts +0 -1
  3. package/dist/cli/index.js +0 -1
  4. package/dist/domain/records.d.ts +161 -0
  5. package/dist/domain/records.js +234 -0
  6. package/dist/entities/fact.d.ts +1 -12
  7. package/dist/entities/fact.js +0 -13
  8. package/dist/entities/index.d.ts +2 -12
  9. package/dist/entities/meeting-findings.d.ts +1 -1
  10. package/dist/errors/reasons.js +10 -0
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/meetings/findings-rules.d.ts +7 -4
  14. package/dist/meetings/findings-rules.js +11 -4
  15. package/dist/meetings/payment.d.ts +10 -6
  16. package/dist/meetings/payment.js +21 -6
  17. package/dist/runs/secrets.d.ts +2 -0
  18. package/dist/runs/secrets.js +2 -0
  19. package/dist/services/index.d.ts +1 -0
  20. package/dist/services/index.js +1 -0
  21. package/dist/services/ingest.d.ts +45 -0
  22. package/dist/services/ingest.js +80 -0
  23. package/dist/services/project-status/index.d.ts +1 -1
  24. package/dist/services/project-status/index.js +7 -3
  25. package/dist/services/project-status/signals/payments.d.ts +3 -3
  26. package/dist/services/project-status/signals/payments.js +43 -17
  27. package/dist/store/evidence-findings.js +16 -8
  28. package/dist/store/index.d.ts +2 -1
  29. package/dist/store/project-transfer/request.d.ts +1 -1
  30. package/dist/store/project-transfer/schema.d.ts +2 -2
  31. package/dist/store/project-transfer/snapshot-model.d.ts +2 -2
  32. package/dist/store/sqlite/accounts.js +4 -0
  33. package/dist/store/sqlite/facts.d.ts +0 -13
  34. package/dist/store/sqlite/facts.js +5 -58
  35. package/dist/store/sqlite/finance.d.ts +4 -0
  36. package/dist/store/sqlite/finance.js +19 -4
  37. package/dist/store/sqlite/findings-core.js +5 -14
  38. package/dist/store/sqlite/manifest.js +2 -0
  39. package/dist/store/sqlite/migrations.generated.js +18 -0
  40. package/dist/store/sqlite/open-store.js +2 -0
  41. package/dist/store/sqlite/payment-entries.d.ts +26 -0
  42. package/dist/store/sqlite/payment-entries.js +91 -0
  43. package/dist/store/sqlite/payment-facts.d.ts +12 -0
  44. package/dist/store/sqlite/payment-facts.js +92 -0
  45. package/dist/store/sqlite/project-transfer/snapshot.d.ts +1 -1
  46. package/dist/store/sqlite/records.d.ts +81 -0
  47. package/dist/store/sqlite/records.js +372 -0
  48. package/dist/store/sqlite/schema.d.ts +353 -0
  49. package/dist/store/sqlite/schema.js +37 -0
  50. package/dist/store/store.d.ts +3 -1
  51. package/dist/store/store.js +5 -0
  52. package/package.json +4 -2
  53. package/schemas/records-envelope.v1.schema.json +108 -0
  54. package/dist/cli/payment-query.d.ts +0 -8
  55. package/dist/cli/payment-query.js +0 -27
@@ -1,7 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { FACT_TYPES, MEMORY_SCOPES } from "../../domain/knowledge.js";
3
3
  import { calendarMoment } from "../../domain/moment.js";
4
- import { validatePayment } from "../../meetings/index.js";
5
4
  import { assertMayDecide, assertMayWithdraw, deciderAuthor } from "../decisions.js";
6
5
  import { proposedBySql, proposerOf } from "./actors.js";
7
6
  import { evidenceSourcesOf } from "./evidence-sources.js";
@@ -36,7 +35,6 @@ export const factOperationsOver = (context) => {
36
35
  scope: row.scope == null ? null : row.scope,
37
36
  source: String(row.source),
38
37
  evidence: evidenceOf(context, { type: "fact", id: Number(row.id) }),
39
- payment: row.type === "payment" ? paymentOf(row.metadata) : null,
40
38
  createdAt: iso(row.created_at),
41
39
  updatedAt: iso(row.updated_at),
42
40
  });
@@ -54,7 +52,6 @@ export const factOperationsOver = (context) => {
54
52
  const decided = (row, by) => assertMayDecide({
55
53
  id: `fact:${row.id}`,
56
54
  proposedBy: proposerOf(database, row.author_type, row.author_id),
57
- ownerOnly: row.type === "payment",
58
55
  }, by, context.decisions);
59
56
  /** Keeps who decided for the owner and why in `metadata.decision`; the decider's actor is in `confirmed_by`. */
60
57
  const keepDecision = (id, by, reason) => {
@@ -83,6 +80,8 @@ export const factOperationsOver = (context) => {
83
80
  };
84
81
  return {
85
82
  add: (input) => {
83
+ if (input.type === "payment")
84
+ throw new CliError("validation_error", "a payment is not a fact: record it with store.finance.addPayment, a payment: row, or an invoice: for one expected", { reason: "fact.payment-retired" });
86
85
  const type = oneOf(input.type, FACT_TYPES, "a fact's type");
87
86
  const statement = input.statement?.trim() ?? "";
88
87
  if (!statement || statement.length > 2000)
@@ -91,7 +90,6 @@ export const factOperationsOver = (context) => {
91
90
  const occurredAt = input.occurredAt === undefined ? null : calendarMoment(input.occurredAt, "occurredAt");
92
91
  if (occurredAt !== null && !Number.isFinite(occurredAt))
93
92
  throw new CliError("validation_error", "occurredAt takes an ISO timestamp");
94
- const payment = paymentInput(type, input);
95
93
  const id = write(database, () => {
96
94
  const project = input.project === undefined ? undefined : requiredThing(database, input.project, "the project");
97
95
  if (project && project.type !== "project")
@@ -100,10 +98,6 @@ export const factOperationsOver = (context) => {
100
98
  const replaced = input.supersedes === undefined ? undefined : rowOf(input.supersedes);
101
99
  if (replaced && replaced.status !== "proposed" && replaced.status !== "accepted")
102
100
  throw new CliError("validation_error", `${input.supersedes} is ${String(replaced.status)}; only a proposed or accepted fact can be corrected`);
103
- if (replaced && (replaced.type === "payment") !== (type === "payment"))
104
- throw new CliError("validation_error", "a payment fact corrects a payment fact, and only one");
105
- if (payment?.counterparty)
106
- requiredThing(database, payment.counterparty, "the counterparty");
107
101
  const owner = input.by === "owner";
108
102
  const { actor, model } = authorActor(context, input.by);
109
103
  const at = now();
@@ -111,7 +105,7 @@ export const factOperationsOver = (context) => {
111
105
  .prepare("INSERT INTO facts (id, type, statement, status, project_id, subject_type, subject_id, occurred_at, supersedes_id, " +
112
106
  "author_type, author_id, model, scope, source, metadata, created_at, updated_at) " +
113
107
  "VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
114
- .get(nextId(database, "facts"), type, statement, project?.id ?? null, subject?.type ?? null, subject?.id ?? null, occurredAt, replaced === undefined ? null : Number(replaced.id), actor.type, actor.id, model, scope, owner ? "owner" : "agent", payment ? JSON.stringify({ payment }) : null, at, at)?.id);
108
+ .get(nextId(database, "facts"), type, statement, project?.id ?? null, subject?.type ?? null, subject?.id ?? null, occurredAt, replaced === undefined ? null : Number(replaced.id), actor.type, actor.id, model, scope, owner ? "owner" : "agent", null, at, at)?.id);
115
109
  linkEvidence(context, { type: "fact", id: made }, input.evidence ?? [], owner ? "owner" : "agent");
116
110
  if (input.createdFrom !== undefined) {
117
111
  const from = requiredThing(database, input.createdFrom, "the source");
@@ -132,12 +126,11 @@ export const factOperationsOver = (context) => {
132
126
  const subject = options.subject === undefined ? undefined : requiredThing(database, options.subject);
133
127
  const by = proposedBySql("f.author_type", "f.author_id", options.proposedBy);
134
128
  const order = options.order === "created" ? "f.created_at" : "coalesce(f.occurred_at, f.created_at)";
135
- const paid = paymentSql(options.payment);
136
129
  return database
137
130
  .prepare(`${NAMED} WHERE f.deleted_at IS NULL AND (? IS NULL OR f.type = ?) AND (? IS NULL OR f.project_id = ?) ` +
138
131
  "AND (? IS NULL OR (f.subject_type = ? AND f.subject_id = ?)) AND (? IS NULL OR f.status = ?) " +
139
- `AND ${by.sql}${paid.sql} ORDER BY ${order} DESC, f.id DESC LIMIT ?`)
140
- .all(options.type ?? null, options.type ?? null, project?.id ?? null, project?.id ?? null, subject?.type ?? null, subject?.type ?? null, subject?.id ?? null, options.status ?? null, options.status ?? null, ...by.params, ...paid.params, Math.min(Math.max(options.limit ?? 100, 1), 500))
132
+ `AND ${by.sql} ORDER BY ${order} DESC, f.id DESC LIMIT ?`)
133
+ .all(options.type ?? null, options.type ?? null, project?.id ?? null, project?.id ?? null, subject?.type ?? null, subject?.type ?? null, subject?.id ?? null, options.status ?? null, options.status ?? null, ...by.params, Math.min(Math.max(options.limit ?? 100, 1), 500))
141
134
  .map(factOf);
142
135
  },
143
136
  accept: (reference, by, reason) => {
@@ -195,52 +188,6 @@ export const factOperationsOver = (context) => {
195
188
  sources: (reference) => evidenceSourcesOf(database, { type: "fact", id: Number(rowOf(reference).id) }),
196
189
  };
197
190
  };
198
- const paymentInput = (type, input) => {
199
- if (type !== "payment") {
200
- if (input.payment !== undefined)
201
- throw new CliError("validation_error", "only a payment fact takes a payment");
202
- return undefined;
203
- }
204
- if (input.payment === undefined)
205
- throw new CliError("validation_error", "a payment fact needs its payment: amount, currency, direction and state");
206
- const evidence = input.evidence?.length ?? 0;
207
- if (evidence < 1 || evidence > 8)
208
- throw new CliError("validation_error", "a payment fact cites 1–8 references: the ask, the transfer or a sheet row");
209
- return validatePayment(input.payment);
210
- };
211
- const paymentOf = (metadata) => {
212
- if (metadata == null)
213
- return null;
214
- try {
215
- const { payment } = JSON.parse(String(metadata));
216
- return payment ?? null;
217
- }
218
- catch {
219
- return null;
220
- }
221
- };
222
- const paymentSql = (filter) => {
223
- if (filter === undefined)
224
- return { sql: "", params: [] };
225
- const parts = ["f.type = 'payment'"];
226
- const params = [];
227
- const state = filter.open ? "expected" : filter.state;
228
- if (filter.open && filter.state !== undefined && filter.state !== "expected")
229
- parts.push("0");
230
- if (filter.direction !== undefined) {
231
- parts.push("json_extract(f.metadata, '$.payment.direction') = ?");
232
- params.push(filter.direction);
233
- }
234
- if (state !== undefined) {
235
- parts.push("json_extract(f.metadata, '$.payment.state') = ?");
236
- params.push(state);
237
- }
238
- if (filter.open)
239
- parts.push("f.status IN ('proposed', 'accepted')");
240
- if (filter.open === false)
241
- parts.push("NOT (json_extract(f.metadata, '$.payment.state') = 'expected' AND f.status IN ('proposed', 'accepted'))");
242
- return { sql: ` AND ${parts.join(" AND ")}`, params };
243
- };
244
191
  const migratedFrom = (metadata) => {
245
192
  try {
246
193
  const { memory } = JSON.parse(metadata);
@@ -28,6 +28,8 @@ interface Reviewed {
28
28
  source: string;
29
29
  /** Why an agent or an import proposed it. */
30
30
  reason: string | null;
31
+ /** What it was read from: the messages, documents or notes an agent or the owner cited. */
32
+ evidence: string[];
31
33
  project: string | null;
32
34
  projectName: string | null;
33
35
  createdAt: string;
@@ -97,6 +99,8 @@ interface Written {
97
99
  source?: string;
98
100
  /** Why a bot proposes it, kept for the reviewer. */
99
101
  reason?: string;
102
+ /** References it was read from, linked as `evidence`. */
103
+ evidence?: string[];
100
104
  }
101
105
  export interface InvoiceLineInput {
102
106
  description: string;
@@ -3,7 +3,7 @@ import { FINANCE_REVIEWS, INVOICE_DIRECTIONS, INVOICE_STATUSES, invoiceTitle, MO
3
3
  import { currencyOf, formatDecimal, lineUnits, minorUnits, moneyLabel, moneyOf, quantityOf, scaleOf, } from "../../domain/money.js";
4
4
  import { assertMayDecide, botKey, deciderAuthor } from "../decisions.js";
5
5
  import { proposedBySql, proposerOf } from "./actors.js";
6
- import { authorActor, authorOf, projectId, projectOf, subjectOf } from "./memories.js";
6
+ import { authorActor, authorOf, evidenceOf, linkEvidence, projectId, projectOf, subjectOf, } from "./memories.js";
7
7
  import { requiredThing } from "./notes.js";
8
8
  import { write } from "./transaction.js";
9
9
  const refuse = (message, reason, code = "validation_error") => {
@@ -30,6 +30,13 @@ const isoDate = (value, what) => {
30
30
  return date;
31
31
  };
32
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
+ };
33
40
  const MAX_LINES = 500;
34
41
  const reasonOf = (metadata) => {
35
42
  if (metadata == null)
@@ -65,7 +72,7 @@ export const financeOperationsOver = (context) => {
65
72
  return refuse(`${what} is a person: or organization: reference, not ${reference}`, "finance.party");
66
73
  return thing;
67
74
  };
68
- const reviewedOf = (row) => ({
75
+ const reviewedOf = (row, type) => ({
69
76
  review: row.review,
70
77
  author: authorOf(row),
71
78
  model: row.model == null ? null : String(row.model),
@@ -74,6 +81,7 @@ export const financeOperationsOver = (context) => {
74
81
  : { type: row.confirmed_by_type, id: String(row.confirmed_by_id) },
75
82
  source: String(row.source),
76
83
  reason: reasonOf(row.metadata),
84
+ evidence: evidenceOf(context, { type, id: Number(row.id) }),
77
85
  ...projectOf(database, row),
78
86
  createdAt: iso(row.created_at),
79
87
  updatedAt: iso(row.updated_at),
@@ -109,7 +117,7 @@ export const financeOperationsOver = (context) => {
109
117
  issueDate: row.issue_date == null ? null : String(row.issue_date),
110
118
  dueDate: fields.dueDate,
111
119
  status: fields.status,
112
- ...reviewedOf(row),
120
+ ...reviewedOf(row, "invoice"),
113
121
  };
114
122
  };
115
123
  const paymentOf = (row) => {
@@ -140,7 +148,7 @@ export const financeOperationsOver = (context) => {
140
148
  reference: row.reference == null ? null : String(row.reference),
141
149
  allocated: formatDecimal(allocated, scale),
142
150
  unallocated: formatDecimal(amount - allocated, scale),
143
- ...reviewedOf(row),
151
+ ...reviewedOf(row, "payment"),
144
152
  };
145
153
  };
146
154
  const allocationsWhere = (column, id) => database
@@ -236,8 +244,13 @@ export const financeOperationsOver = (context) => {
236
244
  confirmedId: owner ? actor.id : null,
237
245
  metadata: reason === null ? null : JSON.stringify({ reason }),
238
246
  project: input.project === undefined ? null : projectId(database, input.project),
247
+ evidence: evidenceList(input.evidence),
239
248
  };
240
249
  };
250
+ const keepEvidence = (type, id, by) => {
251
+ if (by.evidence.length)
252
+ linkEvidence(context, { type, id }, by.evidence, by.owner ? "owner" : "agent");
253
+ };
241
254
  const ownerOnly = (by, what) => {
242
255
  if (by !== "owner")
243
256
  refuse(`only the owner ${what}`, "finance.owner-only", "permission_error");
@@ -361,6 +374,7 @@ export const financeOperationsOver = (context) => {
361
374
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
362
375
  for (const [index, line] of lines.entries())
363
376
  insert.run(made, index + 1, line.description, line.quantity, line.unitPrice, line.amount, at, at);
377
+ keepEvidence("invoice", made, by);
364
378
  return made;
365
379
  });
366
380
  return invoiceDetail(`invoice:${id}`);
@@ -436,6 +450,7 @@ export const financeOperationsOver = (context) => {
436
450
  "reference, project_id, review, author_type, author_id, model, confirmed_by_type, confirmed_by_id, source, " +
437
451
  "metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *")
438
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);
439
454
  const touched = allocations.map(({ invoice, units }) => allocateRow(made, invoice, units, by.actor));
440
455
  for (const invoice of touched)
441
456
  settle(invoice);
@@ -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({
@@ -61,6 +61,8 @@ export const MANIFEST = [
61
61
  { name: "20261102050534_version-44-suggested-links-move", version: 44, minCompatible: 34 },
62
62
  { name: "20261102173102_version-45-finance", version: 45, minCompatible: 34 },
63
63
  { name: "20261102173113_version-45-finance-rules", version: 45, minCompatible: 34 },
64
+ { name: "20261102185954_version-46-payment-facts", version: 46, minCompatible: 34 },
65
+ { name: "20261102192959_version-47-records", version: 47, minCompatible: 34 },
64
66
  ];
65
67
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
66
68
  const migrations = [];
@@ -924,5 +924,23 @@ export const GENERATED = [
924
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
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
926
  ]
927
+ },
928
+ {
929
+ "name": "20261102185954_version-46-payment-facts",
930
+ "statements": [
931
+ "-- Money lives in `payments` and `invoices`, not facts. An older build still takes a payment fact, so the triggers\n-- refuse one; the facts already stored need the finance code's minor units and rules, so the next writable open moves\n-- them and deletes this key (movePaymentFacts).\nCREATE TRIGGER facts_no_payment_bi BEFORE INSERT ON facts WHEN new.type = 'payment' BEGIN\n SELECT RAISE(ABORT, 'facts: a payment is a payments row in store.finance, not a fact; upgrade the CLI');\nEND;",
932
+ "CREATE TRIGGER facts_no_payment_bu BEFORE UPDATE OF type ON facts WHEN new.type = 'payment' BEGIN\n SELECT RAISE(ABORT, 'facts: a payment is a payments row in store.finance, not a fact; upgrade the CLI');\nEND;",
933
+ "INSERT OR REPLACE INTO store_settings (key, value, at) VALUES ('payment_facts_to_move', '1', unixepoch() * 1000);"
934
+ ]
935
+ },
936
+ {
937
+ "name": "20261102192959_version-47-records",
938
+ "statements": [
939
+ "CREATE TABLE `record_relations` (\n\t`id` integer PRIMARY KEY,\n\t`record_id` integer NOT NULL,\n\t`role` text NOT NULL,\n\t`target_type` text NOT NULL,\n\t`target_external_id` text NOT NULL,\n\t`target_id` integer,\n\t`created_at` integer NOT NULL,\n\tCONSTRAINT `fk_record_relations_record_id_records_id_fk` FOREIGN KEY (`record_id`) REFERENCES `records`(`id`),\n\tCONSTRAINT `fk_record_relations_target_id_records_id_fk` FOREIGN KEY (`target_id`) REFERENCES `records`(`id`),\n\tCONSTRAINT `record_relations_record_id_role_target_type_target_external_id_unique` UNIQUE(`record_id`,`role`,`target_type`,`target_external_id`)\n);",
940
+ "CREATE TABLE `records` (\n\t`id` integer PRIMARY KEY,\n\t`account_id` integer NOT NULL,\n\t`type` text NOT NULL,\n\t`external_id` text NOT NULL,\n\t`fields` text NOT NULL,\n\t`revision` integer,\n\t`fingerprint` text NOT NULL,\n\t`entity_type` text,\n\t`entity_id` integer,\n\t`projected_fingerprint` text,\n\t`batch_id` text NOT NULL,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\t`deleted_at` integer,\n\tCONSTRAINT `fk_records_account_id_accounts_id_fk` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`),\n\tCONSTRAINT `records_account_id_type_external_id_unique` UNIQUE(`account_id`,`type`,`external_id`)\n);",
941
+ "CREATE INDEX `record_relations_by_target_type_target_external_id` ON `record_relations` (`target_type`,`target_external_id`);",
942
+ "CREATE INDEX `record_relations_by_target_id` ON `record_relations` (`target_id`);",
943
+ "CREATE INDEX `records_by_entity_type_entity_id` ON `records` (`entity_type`,`entity_id`);"
944
+ ]
927
945
  }
928
946
  ];
@@ -12,6 +12,7 @@ import { fillFolded } from "./folded.js";
12
12
  import { fillJoinKeys } from "./join-keys.js";
13
13
  import { logPruner } from "./log-pruning.js";
14
14
  import { openSqlite } from "./open.js";
15
+ import { movePaymentFacts } from "./payment-facts.js";
15
16
  import { loadProviders } from "./providers.js";
16
17
  import { assertReadCompatible } from "./read-compatibility.js";
17
18
  import { stemsState } from "./stems.js";
@@ -57,6 +58,7 @@ export const openStoreContext = async ({ path, env, now = Date.now, command, ver
57
58
  fillFolded(database);
58
59
  fillJoinKeys(database);
59
60
  moveSuggestedLinks(database, now);
61
+ movePaymentFacts({ ...opened, now, decisions });
60
62
  const pending = pendingNormalization(database);
61
63
  if (pending > 0 && pending <= BACKFILL_ON_OPEN)
62
64
  backfillNormalized(database);
@@ -0,0 +1,26 @@
1
+ import { type Payment } from "../../meetings/index.js";
2
+ import type { Decider } from "../decisions.js";
3
+ import type { Author } from "./memories.js";
4
+ import type { StoreContext } from "./open.js";
5
+ /** A payment as an agent's finding or a retired payment fact holds it. */
6
+ export interface PaymentEntry {
7
+ payment: Payment;
8
+ statement?: string;
9
+ project?: string;
10
+ evidence: string[];
11
+ /** The `invoice:` a paid entry settles or a cancelled one voids. */
12
+ supersedes?: string;
13
+ }
14
+ /** What becomes of the proposal: confirmed or rejected by the decider, or left proposed. */
15
+ export type PaymentOutcome = {
16
+ confirm: boolean;
17
+ by: Decider;
18
+ } | "proposed";
19
+ /** The finding's two-decimal amount in the currency's own scale: "1200.00" JPY is "1200", "1200.50" JPY is refused. */
20
+ export declare const entryAmount: (payment: Payment) => string;
21
+ /**
22
+ * Money from a payment finding goes to `store.finance`, never into a fact: a paid one is a payment, allocated to the
23
+ * invoice it supersedes as far as that is open; an expected one is an invoice of one line; a cancelled one voids its
24
+ * invoice, which only the owner does. The author proposes; `outcome` is the owner's decision on it.
25
+ */
26
+ export declare const paymentEntriesOver: (context: StoreContext) => (entry: PaymentEntry, by: Author, outcome: PaymentOutcome) => string;
@@ -0,0 +1,91 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { formatDecimal, minorUnits, parseDecimal, scaleOf } from "../../domain/money.js";
3
+ import { paymentTitle } from "../../meetings/index.js";
4
+ import { financeOperationsOver } from "./finance.js";
5
+ const refuse = (message, reason) => {
6
+ throw new CliError("validation_error", message, { reason });
7
+ };
8
+ /** The finding's two-decimal amount in the currency's own scale: "1200.00" JPY is "1200", "1200.50" JPY is refused. */
9
+ export const entryAmount = (payment) => {
10
+ const scale = scaleOf(payment.currency);
11
+ const cents = parseDecimal(payment.amount, 2);
12
+ if (cents === undefined)
13
+ return refuse(`${payment.amount} is not an amount`, "money.amount");
14
+ const shift = 10n ** BigInt(Math.abs(scale - 2));
15
+ if (scale >= 2)
16
+ return formatDecimal(cents * shift, scale);
17
+ if (cents % shift !== 0n)
18
+ return refuse(`${payment.currency} has ${scale} decimals; ${payment.amount} does not fit`, "money.amount");
19
+ return formatDecimal(cents / shift, scale);
20
+ };
21
+ const party = (payment) => /^(person|organization):/.test(payment.counterparty ?? "") ? payment.counterparty : null;
22
+ /**
23
+ * Money from a payment finding goes to `store.finance`, never into a fact: a paid one is a payment, allocated to the
24
+ * invoice it supersedes as far as that is open; an expected one is an invoice of one line; a cancelled one voids its
25
+ * invoice, which only the owner does. The author proposes; `outcome` is the owner's decision on it.
26
+ */
27
+ export const paymentEntriesOver = (context) => {
28
+ const finance = financeOperationsOver(context);
29
+ const today = () => new Date(context.now()).toISOString().slice(0, 10);
30
+ const decided = (ref, outcome) => {
31
+ if (outcome === "proposed")
32
+ return ref;
33
+ return (outcome.confirm ? finance.confirm(ref, outcome.by) : finance.reject(ref, outcome.by)).ref;
34
+ };
35
+ /** As much of the payment as the invoice still has open; nothing when it is already covered. */
36
+ const settled = (reference, amount, currency) => {
37
+ const invoice = finance.invoice(reference);
38
+ const units = Math.min(minorUnits(amount, currency), minorUnits(invoice.open, invoice.currency));
39
+ return units > 0 ? [{ invoice: invoice.ref, amount: formatDecimal(units, scaleOf(currency)) }] : [];
40
+ };
41
+ return (entry, by, outcome) => {
42
+ const { payment } = entry;
43
+ const amount = entryAmount(payment);
44
+ const project = entry.project ?? (payment.counterparty?.startsWith("project:") ? payment.counterparty : undefined);
45
+ const written = {
46
+ by,
47
+ ...(project === undefined ? {} : { project }),
48
+ ...(by === "owner" ? {} : { source: "agent", reason: entry.statement ?? paymentTitle(entry) }),
49
+ evidence: entry.evidence,
50
+ };
51
+ const counterparty = party(payment);
52
+ if (payment.state === "cancelled") {
53
+ if (entry.supersedes === undefined)
54
+ return refuse("a cancelled payment names the invoice it cancels", "finance.ref");
55
+ if (outcome === "proposed" || !outcome.confirm)
56
+ return entry.supersedes;
57
+ if (outcome.by !== "owner")
58
+ throw new CliError("permission_error", "only the owner voids an invoice", { reason: "finance.owner-only" });
59
+ return finance.voidInvoice(entry.supersedes, "owner").ref;
60
+ }
61
+ if (payment.state === "expected") {
62
+ if (counterparty === null)
63
+ return refuse("an expected payment is an invoice, which names a person: or organization:", "finance.party");
64
+ const invoice = finance.addInvoice({
65
+ ...written,
66
+ direction: payment.direction === "to_us" ? "receivable" : "payable",
67
+ counterparty,
68
+ currency: payment.currency,
69
+ ...(payment.due_at === null ? {} : { dueDate: payment.due_at }),
70
+ lines: [{ description: (payment.item ?? entry.statement ?? paymentTitle(entry)).slice(0, 500), amount }],
71
+ });
72
+ return decided(invoice.ref, outcome);
73
+ }
74
+ const settles = entry.supersedes === undefined ? [] : settled(entry.supersedes, amount, payment.currency);
75
+ const made = finance.addPayment({
76
+ ...written,
77
+ direction: payment.direction === "to_us" ? "in" : "out",
78
+ ...(counterparty === null
79
+ ? {}
80
+ : payment.direction === "to_us"
81
+ ? { payer: counterparty }
82
+ : { payee: counterparty }),
83
+ amount,
84
+ currency: payment.currency,
85
+ date: payment.paid_at ?? payment.due_at ?? today(),
86
+ ...(payment.item === null ? {} : { reference: payment.item }),
87
+ allocations: settles,
88
+ });
89
+ return decided(made.ref, outcome);
90
+ };
91
+ };
@@ -0,0 +1,12 @@
1
+ import type { StoreContext } from "./open.js";
2
+ /** Set by store version 46 and deleted once the move ran. */
3
+ export declare const PAYMENT_FACTS_TO_MOVE = "payment_facts_to_move";
4
+ /**
5
+ * Moves the payment facts store versions 44–45 took into `store.finance`, as a payment finding is recorded now: a
6
+ * paid one a payment, an expected one an invoice, a cancelled one voids the invoice it supersedes. An accepted fact
7
+ * becomes a confirmed row and a proposed one stays proposed; a rejected one, or one a correction superseded, is
8
+ * rejected. An expected fact that a payment or a cancellation superseded held until then, so its invoice is
9
+ * confirmed and the payment settles it. What cannot be a row (no party for an invoice, a pending cancellation, an
10
+ * owner's fact that no longer holds) stays a `statement` fact with the fields under `metadata.retired_payment`.
11
+ */
12
+ export declare const movePaymentFacts: (context: StoreContext) => number;
@@ -0,0 +1,92 @@
1
+ import { validatePayment } from "../../meetings/index.js";
2
+ import { dropLinksOf, evidenceOf, keepHighestId } from "./memories.js";
3
+ import { paymentEntriesOver } from "./payment-entries.js";
4
+ import { write } from "./transaction.js";
5
+ /** Set by store version 46 and deleted once the move ran. */
6
+ export const PAYMENT_FACTS_TO_MOVE = "payment_facts_to_move";
7
+ const paymentOf = (metadata) => {
8
+ try {
9
+ return validatePayment(JSON.parse(String(metadata)).payment);
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ };
15
+ /**
16
+ * Moves the payment facts store versions 44–45 took into `store.finance`, as a payment finding is recorded now: a
17
+ * paid one a payment, an expected one an invoice, a cancelled one voids the invoice it supersedes. An accepted fact
18
+ * becomes a confirmed row and a proposed one stays proposed; a rejected one, or one a correction superseded, is
19
+ * rejected. An expected fact that a payment or a cancellation superseded held until then, so its invoice is
20
+ * confirmed and the payment settles it. What cannot be a row (no party for an invoice, a pending cancellation, an
21
+ * owner's fact that no longer holds) stays a `statement` fact with the fields under `metadata.retired_payment`.
22
+ */
23
+ export const movePaymentFacts = (context) => {
24
+ const { database, now } = context;
25
+ if (!database.prepare("SELECT 1 AS found FROM store_settings WHERE key = ?").get(PAYMENT_FACTS_TO_MOVE))
26
+ return 0;
27
+ const record = paymentEntriesOver(context);
28
+ return write(database, () => {
29
+ const rows = database
30
+ .prepare("SELECT f.*, b.name AS bot_name, b.kind AS bot_kind FROM facts f " +
31
+ "LEFT JOIN bots b ON f.author_type = 'bot' AND b.id = f.author_id WHERE f.type = 'payment' ORDER BY f.id")
32
+ .all();
33
+ const nextState = (id) => paymentOf(database
34
+ .prepare("SELECT metadata FROM facts WHERE supersedes_id = ? AND type = 'payment' ORDER BY id DESC LIMIT 1")
35
+ .get(id)?.metadata)?.state;
36
+ const outcomeOf = (row, payment) => {
37
+ if (row.status === "proposed")
38
+ return "proposed";
39
+ if (row.status === "accepted")
40
+ return { confirm: true, by: "owner" };
41
+ const settled = row.status === "superseded" && payment.state === "expected" && nextState(Number(row.id));
42
+ return { confirm: settled === "paid" || settled === "cancelled", by: "owner" };
43
+ };
44
+ const moved = new Map();
45
+ for (const row of rows) {
46
+ const id = Number(row.id);
47
+ const payment = paymentOf(row.metadata);
48
+ if (!payment)
49
+ continue;
50
+ const by = row.author_type === "bot" && row.bot_name != null
51
+ ? {
52
+ bot: String(row.bot_name),
53
+ kind: row.bot_kind === "rule" ? "rule" : "agent",
54
+ ...(row.model == null ? {} : { model: String(row.model) }),
55
+ }
56
+ : "owner";
57
+ const outcome = outcomeOf(row, payment);
58
+ const holds = outcome !== "proposed" && outcome.confirm;
59
+ if ((by === "owner" && !holds) || (payment.state === "cancelled" && !holds))
60
+ continue;
61
+ const target = row.supersedes_id == null ? undefined : moved.get(Number(row.supersedes_id));
62
+ const entry = {
63
+ payment,
64
+ statement: String(row.statement),
65
+ ...(row.project_id == null ? {} : { project: `project:${row.project_id}` }),
66
+ evidence: evidenceOf(context, { type: "fact", id }),
67
+ ...(payment.state !== "expected" && target?.startsWith("invoice:") ? { supersedes: target } : {}),
68
+ };
69
+ try {
70
+ moved.set(id, write(database, () => record(entry, by, by === "owner" ? "proposed" : outcome)));
71
+ }
72
+ catch {
73
+ // Kept below as a statement: an unknown currency or a party that is gone is no reason to lose the fact.
74
+ }
75
+ }
76
+ if (moved.size)
77
+ keepHighestId(database, "facts", now());
78
+ for (const id of moved.keys()) {
79
+ database.prepare("UPDATE facts SET supersedes_id = NULL WHERE supersedes_id = ?").run(id);
80
+ dropLinksOf(database, { type: "fact", id });
81
+ database.prepare("DELETE FROM links WHERE from_type = 'fact' AND from_id = ?").run(id);
82
+ database.prepare("DELETE FROM taggings WHERE taggable_type = 'fact' AND taggable_id = ?").run(id);
83
+ database.prepare("DELETE FROM facts WHERE id = ?").run(id);
84
+ }
85
+ database
86
+ .prepare("UPDATE facts SET type = 'statement', updated_at = ?, metadata = json_remove(json_set(coalesce(metadata, '{}'), " +
87
+ "'$.retired_payment', json(json_extract(metadata, '$.payment'))), '$.payment') WHERE type = 'payment'")
88
+ .run(now());
89
+ database.prepare("DELETE FROM store_settings WHERE key = ?").run(PAYMENT_FACTS_TO_MOVE);
90
+ return moved.size;
91
+ });
92
+ };
@@ -5,7 +5,7 @@ export declare const projectAnnotationSnapshot: (database: CacheDatabase, projec
5
5
  format: "wirecat.project-annotations";
6
6
  facts: {
7
7
  ref: string;
8
- type: "decision" | "fact" | "payment" | "promise" | "question" | "statement";
8
+ type: "decision" | "fact" | "promise" | "question" | "statement";
9
9
  statement: string;
10
10
  status: "accepted" | "proposed" | "rejected" | "reversed" | "superseded";
11
11
  subject: string | null;
@@ -0,0 +1,81 @@
1
+ import { type EnvelopeRecord, type EnvelopeSource, type IngestError } from "../../domain/records.js";
2
+ import type { Author } from "./memories.js";
3
+ import type { StoreContext } from "./open.js";
4
+ export interface RecordKey {
5
+ provider: string;
6
+ account: string;
7
+ type: string;
8
+ externalId: string;
9
+ }
10
+ export interface SourceRecord extends RecordKey {
11
+ fields: Record<string, unknown>;
12
+ revision: number | null;
13
+ fingerprint: string;
14
+ relations: {
15
+ role: string;
16
+ type: string;
17
+ externalId: string;
18
+ resolved: boolean;
19
+ }[];
20
+ /** The entity made from it or mapped to it by the owner; `null` while it waits or for a type without a table. */
21
+ entity: string | null;
22
+ /** The source changed or deleted it after its entity was made: the owner should look at the entity. */
23
+ flagged: boolean;
24
+ batchId: string;
25
+ createdAt: string;
26
+ updatedAt: string;
27
+ deletedAt: string | null;
28
+ }
29
+ export interface RecordOutcome {
30
+ /** The record's place in the envelope's `records`. */
31
+ index: number;
32
+ key: string;
33
+ status: "created" | "updated" | "unchanged" | "deleted" | "refused";
34
+ error?: IngestError;
35
+ }
36
+ export interface RecordsWritten {
37
+ outcomes: RecordOutcome[];
38
+ /** `invoice:` and `payment:` refs this import proposed, also for records of earlier imports it completed. */
39
+ proposed: string[];
40
+ /** Kept, with no entity yet: the roles whose target is not imported or has no entity. */
41
+ waiting: {
42
+ index: number;
43
+ key: string;
44
+ missing: string[];
45
+ }[];
46
+ /** Changed or deleted at the source after its entity was made. */
47
+ flagged: {
48
+ index: number;
49
+ key: string;
50
+ entity: string;
51
+ }[];
52
+ /** A record of an earlier import this one completed, whose entity was still refused. */
53
+ laterErrors: IngestError[];
54
+ }
55
+ export interface RecordStore {
56
+ /** Writes validated records of one source, per chunk in one transaction; a dry run rolls everything back. */
57
+ write(source: EnvelopeSource, records: {
58
+ index: number;
59
+ record: EnvelopeRecord;
60
+ }[], options?: {
61
+ dryRun?: boolean;
62
+ chunk?: number;
63
+ }): Promise<RecordsWritten>;
64
+ get(key: RecordKey): Promise<SourceRecord | undefined>;
65
+ list(filter: {
66
+ provider: string;
67
+ account: string;
68
+ type?: string;
69
+ /** Only records whose entity waits for a reference. */
70
+ waiting?: boolean;
71
+ flagged?: boolean;
72
+ /** 1–500, 100 by default. */
73
+ limit?: number;
74
+ }): Promise<SourceRecord[]>;
75
+ /**
76
+ * The owner says which entity a record is: `person:4` for a payer a sheet names. Never done by name. Records that
77
+ * waited for it get their entity, as proposals.
78
+ */
79
+ map(key: RecordKey, entity: string, by: Author): Promise<SourceRecord>;
80
+ }
81
+ export declare const recordStoreOver: (context: StoreContext) => RecordStore;