@wirecat/cli-core 0.570.0 → 0.572.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.572.0 — 05.11.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **`facts.unconfirm` is now `facts.revoke`**, its refusal reason `fact.unconfirm-correction` is
15
+ `fact.revoke-correction`, and the step it writes into a fact's `metadata.history` is `revoked`. memo's command is
16
+ `memo facts revoke`, a word the shared vocabulary has ("take back what was granted"); `unconfirm` is not in it. Nothing
17
+ called the old names but memo's unreleased command, which moves with this.
18
+
19
+ ## 0.571.0 — 05.11.2026
20
+
21
+ ### Added
22
+
23
+ - **An imported payment split across invoices settles each by the amount its source gives (store version 49).** A
24
+ `settles` relation of the v1 records envelope takes an optional `amount`, a decimal in the payment's currency,
25
+ kept in `record_relations.amount`. Additive within schema version 1: an older build refuses such a relation with
26
+ `record.invalid` rather than reading it wrongly. Amounts that add up to more than the payment are refused with
27
+ `record.over-payment` at `/records/<n>/relations/<m>/amount`; an amount on another role with `record.invalid`.
28
+ - **`store.finance.unallocate({ payment, invoice, by })` takes one allocation back** from a payment still proposed,
29
+ under `allocate`'s rule (the owner any, a bot only its own); a confirmed payment's allocations stay
30
+ (`finance.unallocate`), and a missing one is `not_found` (`finance.not-allocated`).
31
+
32
+ ### Fixed
33
+
34
+ - **One import can no longer over-cover an invoice.** A `settles` relation without an amount now takes only what other
35
+ payments, confirmed or still proposed, leave open on the invoice; before, every proposed payment of a batch saw the
36
+ whole invoice open, and the owner's confirm then failed with `finance.over-invoice`.
37
+ - **A changed import updates the allocations of a payment the import proposed and the owner has not decided.** Its
38
+ allocations follow the record's `settles` relations again; it is `flagged` only when more than those changed, or
39
+ once the owner confirmed or rejected it.
40
+
10
41
  ## 0.570.0 — 05.11.2026
11
42
 
12
43
  ### Added
@@ -9,6 +9,8 @@ export interface RecordRelation {
9
9
  role: string;
10
10
  type: string;
11
11
  external_id: string;
12
+ /** A `settles` relation's share of the payment, as a decimal in its currency; without one, as far as both allow. */
13
+ amount?: string;
12
14
  }
13
15
  export interface EnvelopeRecord {
14
16
  /** The caller's name for this line, unique in the envelope; the receipt cites it. */
@@ -143,6 +145,11 @@ export declare const RECORDS_ENVELOPE_SCHEMA: {
143
145
  readonly minLength: 1;
144
146
  readonly maxLength: 512;
145
147
  };
148
+ readonly amount: {
149
+ readonly type: "string";
150
+ readonly pattern: "^[0-9]{1,16}(\\.[0-9]{1,6})?$";
151
+ readonly description: "A settles relation's share of the payment, a decimal in its currency";
152
+ };
146
153
  };
147
154
  };
148
155
  };
@@ -9,6 +9,7 @@ const TYPE = "^(invoice|payment|person|organization|project|custom:[a-z0-9][a-z0
9
9
  const MAX_ID = 512;
10
10
  const MAX_FIELDS_BYTES = 65_536;
11
11
  const MAX_RELATIONS = 100;
12
+ const DECIMAL = "^[0-9]{1,16}(\\.[0-9]{1,6})?$";
12
13
  /** The v1 envelope as JSON Schema (Draft 2020-12); `schemas/records-envelope.v1.schema.json` is this, written out. */
13
14
  export const RECORDS_ENVELOPE_SCHEMA = {
14
15
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -59,12 +60,18 @@ export const RECORDS_ENVELOPE_SCHEMA = {
59
60
  role: { type: "string", pattern: WORD },
60
61
  type: { type: "string", pattern: TYPE },
61
62
  external_id: { type: "string", minLength: 1, maxLength: MAX_ID },
63
+ amount: {
64
+ type: "string",
65
+ pattern: DECIMAL,
66
+ description: "A settles relation's share of the payment, a decimal in its currency",
67
+ },
62
68
  },
63
69
  },
64
70
  },
65
71
  };
66
72
  const wordPattern = new RegExp(WORD);
67
73
  const typePattern = new RegExp(TYPE);
74
+ const decimalPattern = new RegExp(DECIMAL);
68
75
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
69
76
  const escapePointer = (segment) => String(segment).replaceAll("~", "~0").replaceAll("/", "~1");
70
77
  export const pointerOf = (...segments) => segments.map((s) => `/${escapePointer(s)}`).join("");
@@ -102,15 +109,19 @@ const relationErrors = (relations, at) => {
102
109
  const here = [...at, index];
103
110
  if (!isObject(relation))
104
111
  return [invalid(here, "a relation is an object")];
105
- const extra = extraKeys(relation, ["role", "type", "external_id"]);
112
+ const extra = extraKeys(relation, ["role", "type", "external_id", "amount"]);
106
113
  if (extra.length > 0)
107
- return [invalid([...here, extra[0]], "a relation holds role, type and external_id")];
114
+ return [invalid([...here, extra[0]], "a relation holds role, type, external_id and amount")];
108
115
  if (typeof relation.role !== "string" || !wordPattern.test(relation.role))
109
116
  return [invalid([...here, "role"], "a role is lowercase letters, digits, - and _")];
110
117
  if (typeof relation.type !== "string" || !typePattern.test(relation.type))
111
118
  return [invalid([...here, "type"], `a type is one of ${RECORD_ENTITY_TYPES.join(", ")} or custom:<name>`)];
112
119
  if (!idText(relation.external_id))
113
120
  return [invalid([...here, "external_id"], `an external id takes 1–${MAX_ID} characters`)];
121
+ if (relation.amount !== undefined && (typeof relation.amount !== "string" || !decimalPattern.test(relation.amount)))
122
+ return [invalid([...here, "amount"], 'an amount is a decimal string, such as "13000.00"')];
123
+ if (relation.amount !== undefined && relation.role !== "settles")
124
+ return [invalid([...here, "amount"], "only a settles relation carries an amount")];
114
125
  return [];
115
126
  });
116
127
  };
@@ -202,7 +213,13 @@ export const recordFingerprint = (fields, relations) => createHash("sha256")
202
213
  .update(JSON.stringify(canonical({
203
214
  fields,
204
215
  relations: relations
205
- .map((relation) => [relation.role, relation.type, relation.external_id])
216
+ // A relation without an amount keeps the fingerprint it had before amounts existed.
217
+ .map((relation) => [
218
+ relation.role,
219
+ relation.type,
220
+ relation.external_id,
221
+ ...(relation.amount === undefined ? [] : [relation.amount]),
222
+ ])
206
223
  .sort((a, b) => a.join("\0").localeCompare(b.join("\0"))),
207
224
  })))
208
225
  .digest("hex");
@@ -164,15 +164,16 @@ const OTHERS = [
164
164
  validation("input.required", "the command needs an answer and cannot ask for one"),
165
165
  validation("input.too-large", "the input is over its size limit"),
166
166
  ["meeting-project.stale", "configuration_error", "the meeting's project changed meanwhile"],
167
- validation("fact.not-accepted", "only an accepted fact can be unconfirmed"),
168
- validation("fact.owner-written", "the owner wrote this fact; it was never proposed, so it cannot be unconfirmed"),
167
+ validation("fact.not-accepted", "only an accepted fact can be revoked"),
168
+ validation("fact.owner-written", "the owner wrote this fact; it was never proposed, so it cannot be revoked"),
169
169
  validation("fact.payment-retired", "a payment is a payments row in store.finance, not a fact"),
170
- validation("fact.unconfirm-correction", "an accepted correction cannot be unconfirmed; its superseded fact stays"),
170
+ validation("fact.revoke-correction", "an accepted correction cannot be revoked; its superseded fact stays"),
171
171
  validation("finance.allocate", "the invoice or payment cannot take an allocation in its state"),
172
172
  validation("finance.currency", "the payment and the invoice are in different currencies; nothing is converted"),
173
173
  validation("finance.field", "a field of the invoice or payment is not valid"),
174
174
  validation("finance.line-amount", "the line's amount is not its quantity times its unit price"),
175
175
  validation("finance.lines", "the invoice's lines are not valid"),
176
+ ["finance.not-allocated", "not_found", "the payment is not allocated to that invoice"],
176
177
  validation("finance.over-invoice", "the allocation would settle more than the invoice's total"),
177
178
  validation("finance.over-payment", "the allocation would use more than the payment's amount"),
178
179
  ["finance.owner-only", "permission_error", "only the owner does this with money"],
@@ -180,10 +181,12 @@ const OTHERS = [
180
181
  validation("finance.ref", "the reference names no invoice or payment"),
181
182
  validation("finance.status", "the invoice's status does not allow this"),
182
183
  validation("finance.total", "the invoice's total is not the sum of its lines"),
184
+ validation("finance.unallocate", "only a proposed payment's allocations are taken back"),
183
185
  validation("record.entity", "the invoice or payment the record stands for was refused"),
184
186
  ["record.map-owner-only", "permission_error", "only the owner says which entity a source record is"],
185
187
  validation("record.map-type", "a record maps only to an entity of its own type"),
186
188
  validation("record.mapped", "the record already stands for another entity"),
189
+ validation("record.over-payment", "the record's settles amounts add up to more than the payment"),
187
190
  validation("record.not-found", "the store holds no such record to delete"),
188
191
  validation("record.invalid", "a field of the record is not the kind the entity needs"),
189
192
  validation("record.reference-not-found", "a reference in the record's fields is not in the store"),
@@ -74,7 +74,7 @@ export interface FactsStore {
74
74
  * The owner takes back the approval of an accepted fact an agent proposed: it waits as `proposed` again. The step is
75
75
  * kept in `metadata.history`, with the decision it took back.
76
76
  */
77
- unconfirm(reference: string, by: Decider, reason?: string): Promise<Fact>;
77
+ revoke(reference: string, by: Decider, reason?: string): Promise<Fact>;
78
78
  /** The owner, or an agent for the owner, turns down a proposed fact; it stays, marked `rejected`. */
79
79
  reject(reference: string, by: Decider, reason?: string): Promise<Fact>;
80
80
  /**
@@ -154,24 +154,24 @@ export const factOperationsOver = (context) => {
154
154
  database.prepare("UPDATE facts SET status = 'reversed', updated_at = ? WHERE id = ?").run(now(), Number(row.id));
155
155
  return factOf(rowOf(reference));
156
156
  },
157
- unconfirm: (reference, by, reason) => {
157
+ revoke: (reference, by, reason) => {
158
158
  const row = rowOf(reference);
159
159
  const ref = `fact:${row.id}`;
160
160
  assertMayWithdraw({ id: ref, proposedBy: { type: "unknown" }, proposed: false }, by);
161
161
  write(database, () => {
162
162
  const current = database.prepare("SELECT * FROM facts WHERE id = ?").get(Number(row.id));
163
163
  if (current.status !== "accepted")
164
- throw new CliError("validation_error", `only an accepted fact can be unconfirmed; ${ref} is ${String(current.status)}`, { reason: "fact.not-accepted" });
164
+ throw new CliError("validation_error", `only an accepted fact can be revoked; ${ref} is ${String(current.status)}`, { reason: "fact.not-accepted" });
165
165
  if (current.author_type === "person")
166
166
  throw new CliError("validation_error", `${ref} is the owner's own fact, never proposed; correct it with a fact that supersedes it, or remove it`, { reason: "fact.owner-written" });
167
167
  // Accepting it marked the fact it corrects superseded, and what that one was before is not kept.
168
168
  if (current.supersedes_id != null)
169
169
  throw new CliError("validation_error", `${ref} corrects fact:${current.supersedes_id}, which stopped holding when ${ref} was accepted; ` +
170
- "remove it or correct it with another fact instead", { reason: "fact.unconfirm-correction" });
170
+ "remove it or correct it with another fact instead", { reason: "fact.revoke-correction" });
171
171
  const at = now();
172
172
  const { decision, history, ...metadata } = (current.metadata == null ? {} : JSON.parse(String(current.metadata)));
173
173
  const step = {
174
- step: "unconfirmed",
174
+ step: "revoked",
175
175
  by: "owner",
176
176
  at: iso(at),
177
177
  confirmedBy: { type: current.confirmed_by_type, id: String(current.confirmed_by_id) },
@@ -236,7 +236,7 @@ export const factsStoreOver = (context) => {
236
236
  list: async (options) => operations.list(options),
237
237
  accept: async (reference, by, reason) => operations.accept(reference, by, reason),
238
238
  reverse: async (reference, by) => operations.reverse(reference, by),
239
- unconfirm: async (reference, by, reason) => operations.unconfirm(reference, by, reason),
239
+ revoke: async (reference, by, reason) => operations.revoke(reference, by, reason),
240
240
  reject: async (reference, by, reason) => operations.reject(reference, by, reason),
241
241
  remove: async (reference, by) => operations.remove(reference, by),
242
242
  sources: async (reference) => operations.sources(reference),
@@ -180,6 +180,15 @@ export interface FinanceStore {
180
180
  amount: string;
181
181
  by: Author;
182
182
  }): Promise<Allocation>;
183
+ /**
184
+ * Takes one allocation back from a payment still proposed, under the same rule as `allocate`. A confirmed payment's
185
+ * allocations are decided money and stay.
186
+ */
187
+ unallocate(input: {
188
+ payment: string;
189
+ invoice: string;
190
+ by: Author;
191
+ }): Promise<PaymentDetail>;
183
192
  /**
184
193
  * The owner confirms a proposed `invoice:` or `payment:`; only the owner decides money. A payment whose allocations
185
194
  * would settle more than an invoice's total, given the confirmed payments by then, is refused and stays proposed.
@@ -319,6 +319,13 @@ export const financeOperationsOver = (context) => {
319
319
  });
320
320
  return type === "invoice" ? invoiceDetail(ref) : paymentDetail(ref);
321
321
  };
322
+ const mayAllocate = (row, by) => {
323
+ if (by === "owner")
324
+ return;
325
+ const own = row.author_type === "bot" && row.author_name != null && botKey(String(row.author_name)) === botKey(by.bot);
326
+ if (row.review !== "proposed" || !own)
327
+ refuse(`only the owner allocates payment:${row.id}; a bot allocates only from a payment it proposed that is still proposed`, "finance.owner-only", "permission_error");
328
+ };
322
329
  const limitOf = (limit) => Math.min(Math.max(limit ?? 100, 1), 500);
323
330
  return {
324
331
  addInvoice: (input) => {
@@ -474,11 +481,7 @@ export const financeOperationsOver = (context) => {
474
481
  allocate: ({ payment, invoice, amount, by }) => {
475
482
  const row = rowOf(payment, "payment");
476
483
  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
- }
484
+ mayAllocate(row, by);
482
485
  if (row.review === "rejected" || row.deleted_at != null)
483
486
  refuse(`${ref} is ${String(row.review)}`, "finance.allocate");
484
487
  const currency = String(row.currency);
@@ -494,6 +497,26 @@ export const financeOperationsOver = (context) => {
494
497
  const found = allocationsWhere("payment_id", Number(row.id)).find((one) => one.invoice === `invoice:${invoiceId}`);
495
498
  return found;
496
499
  },
500
+ unallocate: ({ payment, invoice, by }) => {
501
+ const row = rowOf(payment, "payment");
502
+ const ref = `payment:${row.id}`;
503
+ mayAllocate(row, by);
504
+ if (row.review !== "proposed" || row.deleted_at != null)
505
+ refuse(`${ref} is ${row.deleted_at != null ? "deleted" : String(row.review)}; only a proposed payment's ` +
506
+ "allocations are taken back", "finance.unallocate");
507
+ const target = rowOf(invoice, "invoice");
508
+ write(database, () => {
509
+ const gone = database
510
+ .prepare("DELETE FROM payment_allocations WHERE payment_id = ? AND invoice_id = ?")
511
+ .run(Number(row.id), Number(target.id));
512
+ if (gone.changes === 0)
513
+ throw new CliError("not_found", `${ref} is not allocated to invoice:${target.id}`, {
514
+ reason: "finance.not-allocated",
515
+ });
516
+ settle(Number(target.id));
517
+ });
518
+ return paymentDetail(ref);
519
+ },
497
520
  confirm: (reference, by, reason) => decide(reference, by, reason, true),
498
521
  reject: (reference, by, reason) => decide(reference, by, reason, false),
499
522
  openTotals: (filter = {}) => {
@@ -522,6 +545,7 @@ export const financeStoreOver = (context) => {
522
545
  payment: async (reference) => operations.payment(reference),
523
546
  payments: async (filter) => operations.payments(filter),
524
547
  allocate: async (input) => operations.allocate(input),
548
+ unallocate: async (input) => operations.unallocate(input),
525
549
  confirm: async (reference, by, reason) => operations.confirm(reference, by, reason),
526
550
  reject: async (reference, by, reason) => operations.reject(reference, by, reason),
527
551
  openTotals: async (filter) => operations.openTotals(filter),
@@ -64,6 +64,7 @@ export const MANIFEST = [
64
64
  { name: "20261102185954_version-46-payment-facts", version: 46, minCompatible: 34 },
65
65
  { name: "20261102192959_version-47-records", version: 47, minCompatible: 34 },
66
66
  { name: "20261102222340_version-48-test-accounts", version: 48, minCompatible: 34 },
67
+ { name: "20261104232153_version-49-relation-amounts", version: 49, minCompatible: 34 },
67
68
  ];
68
69
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
69
70
  const migrations = [];
@@ -948,5 +948,11 @@ export const GENERATED = [
948
948
  "statements": [
949
949
  "-- Tests wrote the fake `chat` messenger of src/testing/shared-program.ts into real stores until a test could no longer\n-- open one (#2438). Deleting an account is the store's own purge, in code (every child row, the people only it saw,\n-- the vectors only it held), so the next writable open removes every `chat` account and deletes this key\n-- (purgeTestAccounts). A store without one gets no key, so a test's own `chat` rows written after this stay.\nINSERT OR REPLACE INTO store_settings (key, value, at)\nSELECT 'test_accounts_to_purge', '1', unixepoch() * 1000 WHERE EXISTS (SELECT 1 FROM accounts WHERE provider = 'chat');"
950
950
  ]
951
+ },
952
+ {
953
+ "name": "20261104232153_version-49-relation-amounts",
954
+ "statements": [
955
+ "ALTER TABLE `record_relations` ADD `amount` text;"
956
+ ]
951
957
  }
952
958
  ];
@@ -15,6 +15,7 @@ export interface SourceRecord extends RecordKey {
15
15
  role: string;
16
16
  type: string;
17
17
  externalId: string;
18
+ amount: string | null;
18
19
  resolved: boolean;
19
20
  }[];
20
21
  /** The entity made from it or mapped to it by the owner; `null` while it waits or for a type without a table. */
@@ -23,6 +23,8 @@ const codeOf = (error) => {
23
23
  return reason;
24
24
  return error.code === "not_found" ? "record.reference-not-found" : "record.entity";
25
25
  };
26
+ /** A record's fingerprint without its settles relations: what an import may not change on a payment it proposed. */
27
+ const unsettled = (fields, relations) => recordFingerprint(fields, relations.filter((relation) => relation.role !== "settles"));
26
28
  const iso = (value) => new Date(Number(value)).toISOString();
27
29
  const stringField = (fields, name) => {
28
30
  const value = fields[name];
@@ -87,12 +89,13 @@ export const recordStoreOver = (context) => {
87
89
  return statement;
88
90
  };
89
91
  const entityRef = (type, id) => type == null ? null : (referenceOfThing(database, storedThing(type, id)) ?? `${String(type)}:${String(id)}`);
90
- /** Roles a projection needs whose target has no entity yet, and the entities of the rest. */
92
+ /** Roles a projection needs whose target has no entity yet, the entities of the rest, and what each invoice gets. */
91
93
  const relatedOf = (recordId, type) => {
92
94
  const { roles, many } = PROJECTED[type];
93
- const rows = sql("SELECT rr.role, t.entity_type, t.entity_id FROM record_relations rr " +
94
- "LEFT JOIN records t ON t.id = rr.target_id AND t.deleted_at IS NULL WHERE rr.record_id = ?").all(recordId);
95
+ const rows = sql("SELECT rr.role, rr.amount, t.entity_type, t.entity_id FROM record_relations rr " +
96
+ "LEFT JOIN records t ON t.id = rr.target_id AND t.deleted_at IS NULL WHERE rr.record_id = ? ORDER BY rr.id").all(recordId);
95
97
  const related = new Map();
98
+ const shares = [];
96
99
  const missing = new Set();
97
100
  for (const row of rows) {
98
101
  const role = String(row.role);
@@ -103,41 +106,106 @@ export const recordStoreOver = (context) => {
103
106
  missing.add(role);
104
107
  else
105
108
  related.set(role, [...(related.get(role) ?? []), ref]);
109
+ if (ref !== null && role === "settles")
110
+ shares.push({ invoice: ref, amount: row.amount == null ? null : String(row.amount) });
106
111
  }
107
112
  for (const [role, refs] of related)
108
113
  if (refs.length > 1 && !many.includes(role))
109
114
  refusal(`a record has one ${role}`, "record.relation-ambiguous");
110
- return { related, missing: [...missing] };
115
+ return { related, shares, missing: [...missing] };
111
116
  };
112
117
  /** Makes the proposal a waiting invoice or payment record stands for, once every reference it names has an entity. */
113
118
  const project = (row, source) => {
114
119
  const type = row.type;
115
- const { related, missing } = relatedOf(Number(row.id), type);
120
+ const { related, shares, missing } = relatedOf(Number(row.id), type);
116
121
  if (missing.length > 0)
117
122
  return { missing };
118
123
  const fields = JSON.parse(String(row.fields));
119
124
  for (const name of REFERENCE_FIELDS)
120
125
  stringField(fields, name);
121
- const ref = type === "invoice" ? finance.addInvoice(invoiceOf(fields, related, source)).ref : settle(fields, related, source);
126
+ const ref = type === "invoice"
127
+ ? finance.addInvoice(invoiceOf(fields, related, source)).ref
128
+ : settle(finance.addPayment(paymentOf(fields, related, source)), shares);
122
129
  const entity = requiredThing(database, ref);
123
130
  sql("UPDATE records SET entity_type = ?, entity_id = ?, projected_fingerprint = fingerprint WHERE id = ?").run(entity.type, entity.id, Number(row.id));
124
131
  return { proposed: ref, missing: [] };
125
132
  };
126
- /** A payment settles each invoice it names as far as both allow, in the order named. */
127
- const settle = (fields, related, source) => {
128
- const made = finance.addPayment(paymentOf(fields, related, source));
133
+ /**
134
+ * What other payments leave open on an invoice. Proposed ones count too: the owner confirms them later, so payments
135
+ * of one batch would otherwise each take the whole invoice.
136
+ */
137
+ const freeOn = (invoice, payment) => {
138
+ const [target, own] = [requiredThing(database, invoice), requiredThing(database, payment)];
139
+ const row = sql("SELECT i.total - (SELECT coalesce(sum(a.amount), 0) FROM payment_allocations a JOIN payments p ON p.id = " +
140
+ "a.payment_id WHERE a.invoice_id = i.id AND a.payment_id <> ? AND p.deleted_at IS NULL " +
141
+ "AND p.review <> 'rejected') AS free FROM invoices i WHERE i.id = ?").get(own.id, target.id);
142
+ return Number(row?.free ?? 0);
143
+ };
144
+ /** A payment settles each invoice it names by the amount the source gives, then the rest as far as both allow. */
145
+ const settle = (made, shares) => {
129
146
  const units = (amount) => minorUnits(amount, made.currency);
130
147
  let left = units(made.unallocated);
131
- for (const invoice of related.get("settles") ?? []) {
132
- const share = Math.min(left, units(finance.invoice(invoice).open));
148
+ const given = shares.filter((one) => one.amount !== null);
149
+ for (const { invoice, amount } of [...given, ...shares.filter((one) => one.amount === null)]) {
150
+ const share = amount === null ? Math.min(left, freeOn(invoice, made.ref)) : units(amount);
133
151
  if (share <= 0)
134
152
  continue;
135
- const amount = formatDecimal(share, scaleOf(made.currency));
136
- finance.allocate({ payment: made.ref, invoice, amount, by: IMPORTER });
153
+ finance.allocate({
154
+ payment: made.ref,
155
+ invoice,
156
+ amount: formatDecimal(share, scaleOf(made.currency)),
157
+ by: IMPORTER,
158
+ });
137
159
  left -= share;
138
160
  }
139
161
  return made.ref;
140
162
  };
163
+ /** A payment the import proposed and the owner has not decided yet takes its record's settles relations again. */
164
+ const resettle = (recordId, ref) => {
165
+ const payment = finance.payment(ref);
166
+ if (payment.review !== "proposed" || payment.author.type !== "bot" || payment.author.name !== IMPORTER.bot)
167
+ return false;
168
+ for (const { invoice } of payment.allocations)
169
+ finance.unallocate({ payment: ref, invoice, by: IMPORTER });
170
+ settle(finance.payment(ref), relatedOf(recordId, "payment").shares);
171
+ return true;
172
+ };
173
+ /** The settles amounts a payment record gives, refused at the first one that is not money or goes past the payment. */
174
+ const checkShares = (record) => {
175
+ const relations = record.relations ?? [];
176
+ if (record.type !== "payment" || !relations.some((relation) => relation.amount !== undefined))
177
+ return;
178
+ const fields = record.fields ?? {};
179
+ if (typeof fields.currency !== "string")
180
+ return;
181
+ const currency = fields.currency;
182
+ let left;
183
+ try {
184
+ left = minorUnits(fields.amount, currency);
185
+ }
186
+ catch {
187
+ return;
188
+ }
189
+ for (const [index, { amount }] of relations.entries()) {
190
+ if (amount === undefined)
191
+ continue;
192
+ const at = ["relations", index, "amount"];
193
+ let units = 0;
194
+ try {
195
+ units = minorUnits(amount, currency, "a settles amount");
196
+ }
197
+ catch (error) {
198
+ if (!(error instanceof CliError))
199
+ throw error;
200
+ refusal(error.message, codeOf(error), at);
201
+ }
202
+ if (units <= 0)
203
+ refusal("a settles amount is more than 0", "money.amount", at);
204
+ left -= units;
205
+ if (left < 0)
206
+ refusal(`the settles amounts add up to more than the payment's ${String(fields.amount)} ${currency}`, "record.over-payment", at);
207
+ }
208
+ };
141
209
  /** Records that waited on `target` and may now have their entity, and theirs in turn. */
142
210
  const completeDependents = (target, source, proposed, laterErrors) => {
143
211
  const queue = [target];
@@ -164,11 +232,19 @@ export const recordStoreOver = (context) => {
164
232
  }
165
233
  }
166
234
  };
235
+ const storedRelations = (recordId) => sql("SELECT role, target_type, target_external_id FROM record_relations WHERE record_id = ?")
236
+ .all(recordId)
237
+ .map((row) => ({
238
+ role: String(row.role),
239
+ type: String(row.target_type),
240
+ external_id: String(row.target_external_id),
241
+ }));
167
242
  const relate = (recordId, accountId, relations, at) => {
168
243
  sql("DELETE FROM record_relations WHERE record_id = ?").run(recordId);
169
244
  for (const relation of relations)
170
- sql("INSERT OR IGNORE INTO record_relations (record_id, role, target_type, target_external_id, target_id, created_at) " +
171
- "VALUES (?, ?, ?, ?, (SELECT id FROM records WHERE account_id = ? AND type = ? AND external_id = ?), ?)").run(recordId, relation.role, relation.type, relation.external_id, accountId, relation.type, relation.external_id, at);
245
+ sql("INSERT OR IGNORE INTO record_relations (record_id, role, target_type, target_external_id, target_id, " +
246
+ "created_at, amount) VALUES (?, ?, ?, ?, (SELECT id FROM records WHERE account_id = ? AND type = ? " +
247
+ "AND external_id = ?), ?, ?)").run(recordId, relation.role, relation.type, relation.external_id, accountId, relation.type, relation.external_id, at, relation.amount ?? null);
172
248
  };
173
249
  const writeOne = (accountId, source, { record }, proposed) => {
174
250
  const stored = sql("SELECT * FROM records WHERE account_id = ? AND type = ? AND external_id = ?").get(accountId, record.type, record.external_id);
@@ -197,6 +273,12 @@ export const recordStoreOver = (context) => {
197
273
  const fields = JSON.stringify(record.fields);
198
274
  if (stored && stored.fingerprint === fingerprint && stored.deleted_at == null)
199
275
  return { status: "unchanged", id: Number(stored.id) };
276
+ checkShares(record);
277
+ // Whether the payment the record stands for differs in more than whom it settles, against the stored version.
278
+ const beyondSettles = stored?.entity_id != null &&
279
+ (stored.projected_fingerprint !== stored.fingerprint ||
280
+ unsettled(JSON.parse(String(stored.fields)), storedRelations(Number(stored.id))) !==
281
+ unsettled(record.fields ?? {}, relations));
200
282
  const id = stored
201
283
  ? Number(stored.id)
202
284
  : Number(sql("INSERT INTO records (account_id, type, external_id, fields, revision, fingerprint, batch_id, created_at, " +
@@ -210,16 +292,27 @@ export const recordStoreOver = (context) => {
210
292
  "AND record_id IN (SELECT id FROM records WHERE account_id = ?)").run(id, record.type, record.external_id, accountId);
211
293
  relate(id, accountId, relations, time);
212
294
  let projected = false;
295
+ let flagged = stored?.entity_id != null && stored.projected_fingerprint !== fingerprint;
213
296
  if (isProjected(record.type) && stored?.entity_id == null) {
214
297
  const done = project({ id, type: record.type, fields, account_id: accountId }, source);
215
298
  if (done.proposed)
216
299
  proposed.push(done.proposed);
217
300
  projected = done.proposed !== undefined;
218
301
  }
302
+ else if (record.type === "payment" && stored?.entity_type === "payment" && stored.deleted_at == null) {
303
+ const ref = `payment:${Number(stored.entity_id)}`;
304
+ if (resettle(id, ref)) {
305
+ proposed.push(ref);
306
+ if (!beyondSettles) {
307
+ sql("UPDATE records SET projected_fingerprint = fingerprint WHERE id = ?").run(id);
308
+ flagged = false;
309
+ }
310
+ }
311
+ }
219
312
  return {
220
313
  status: stored ? "updated" : "created",
221
314
  id,
222
- flagged: stored?.entity_id != null && stored.projected_fingerprint !== fingerprint,
315
+ flagged,
223
316
  // Records waiting on this one move only when it has an entity: made now, or back from a delete.
224
317
  complete: projected || (stored?.deleted_at != null && stored.entity_id != null),
225
318
  };
@@ -265,7 +358,7 @@ export const recordStoreOver = (context) => {
265
358
  if (!(error instanceof CliError))
266
359
  throw error;
267
360
  const at = error.details?.at ?? "fields";
268
- const pointer = pointerOf("records", item.index, at);
361
+ const pointer = pointerOf("records", item.index, ...[at].flat());
269
362
  outcomes.push({
270
363
  index: item.index,
271
364
  key: item.record.key,
@@ -292,12 +385,14 @@ export const recordStoreOver = (context) => {
292
385
  fields: JSON.parse(String(row.fields)),
293
386
  revision: row.revision == null ? null : Number(row.revision),
294
387
  fingerprint: String(row.fingerprint),
295
- relations: sql("SELECT role, target_type, target_external_id, target_id FROM record_relations WHERE record_id = ? ORDER BY id")
388
+ relations: sql("SELECT role, target_type, target_external_id, target_id, amount FROM record_relations WHERE record_id = ? " +
389
+ "ORDER BY id")
296
390
  .all(Number(row.id))
297
391
  .map((relation) => ({
298
392
  role: String(relation.role),
299
393
  type: String(relation.target_type),
300
394
  externalId: String(relation.target_external_id),
395
+ amount: relation.amount == null ? null : String(relation.amount),
301
396
  resolved: relation.target_id != null,
302
397
  })),
303
398
  entity: entityRef(row.entity_type, row.entity_id),
@@ -15239,6 +15239,22 @@ export declare const recordRelations: import("drizzle-orm/sqlite-core").SQLiteTa
15239
15239
  identity: undefined;
15240
15240
  generated: undefined;
15241
15241
  }, {}>;
15242
+ amount: import("drizzle-orm/sqlite-core").SQLiteColumn<{
15243
+ name: string;
15244
+ tableName: "record_relations";
15245
+ dataType: "string";
15246
+ data: string;
15247
+ driverParam: string;
15248
+ notNull: false;
15249
+ hasDefault: false;
15250
+ isPrimaryKey: false;
15251
+ isAutoincrement: false;
15252
+ hasRuntimeDefault: false;
15253
+ enumValues: [string, ...string[]];
15254
+ baseColumn: never;
15255
+ identity: undefined;
15256
+ generated: undefined;
15257
+ }, {}>;
15242
15258
  };
15243
15259
  dialect: 'sqlite';
15244
15260
  isAlias: false;
@@ -1524,6 +1524,7 @@ export const recordRelations = sqliteTable("record_relations", {
1524
1524
  targetExternalId: text("target_external_id").notNull(),
1525
1525
  targetId: integer("target_id").references(() => records.id),
1526
1526
  createdAt: integer("created_at").notNull(),
1527
+ amount: text("amount"),
1527
1528
  }, (table) => [
1528
1529
  unique().on(table.recordId, table.role, table.targetType, table.targetExternalId),
1529
1530
  index("record_relations_by_target_type_target_external_id").on(table.targetType, table.targetExternalId),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.570.0",
3
+ "version": "0.572.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
@@ -101,6 +101,11 @@
101
101
  "type": "string",
102
102
  "minLength": 1,
103
103
  "maxLength": 512
104
+ },
105
+ "amount": {
106
+ "type": "string",
107
+ "pattern": "^[0-9]{1,16}(\\.[0-9]{1,6})?$",
108
+ "description": "A settles relation's share of the payment, a decimal in its currency"
104
109
  }
105
110
  }
106
111
  }