@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 +31 -0
- package/dist/domain/records.d.ts +7 -0
- package/dist/domain/records.js +20 -3
- package/dist/errors/reasons.js +6 -3
- package/dist/store/sqlite/facts.d.ts +1 -1
- package/dist/store/sqlite/facts.js +5 -5
- package/dist/store/sqlite/finance.d.ts +9 -0
- package/dist/store/sqlite/finance.js +29 -5
- package/dist/store/sqlite/manifest.js +1 -0
- package/dist/store/sqlite/migrations.generated.js +6 -0
- package/dist/store/sqlite/records.d.ts +1 -0
- package/dist/store/sqlite/records.js +113 -18
- package/dist/store/sqlite/schema.d.ts +16 -0
- package/dist/store/sqlite/schema.js +1 -0
- package/package.json +1 -1
- package/schemas/records-envelope.v1.schema.json +5 -0
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
|
package/dist/domain/records.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/domain/records.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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");
|
package/dist/errors/reasons.js
CHANGED
|
@@ -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
|
|
168
|
-
validation("fact.owner-written", "the owner wrote this fact; it was never proposed, so it cannot be
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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"
|
|
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
|
-
/**
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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,
|
|
171
|
-
"VALUES (?, ?, ?, ?, (SELECT id FROM records WHERE account_id = ? AND type = ?
|
|
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
|
|
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 = ?
|
|
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.
|
|
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
|
}
|