@wirecat/cli-core 0.574.0 → 0.575.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 +16 -0
- package/dist/calendar/mcp/index.d.ts +12 -12
- package/dist/cli/findings-input.js +6 -0
- package/dist/domain/orders.d.ts +19 -0
- package/dist/domain/orders.js +12 -0
- package/dist/domain/ref-summaries.js +1 -0
- package/dist/domain/references.d.ts +1 -1
- package/dist/domain/references.js +1 -0
- package/dist/entities/index.d.ts +75 -0
- package/dist/entities/mail-findings.d.ts +75 -0
- package/dist/entities/mail-findings.js +62 -0
- package/dist/errors/reasons.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mail/findings.d.ts +73 -1
- package/dist/mail/findings.js +164 -1
- package/dist/services/proposals/kinds/mail-findings.js +9 -0
- package/dist/services/proposals/kinds/orders.d.ts +4 -0
- package/dist/services/proposals/kinds/orders.js +32 -0
- package/dist/services/proposals/registry.js +2 -0
- package/dist/store/index.d.ts +1 -0
- package/dist/store/sqlite/entity-types.d.ts +1 -0
- package/dist/store/sqlite/entity-types.js +1 -0
- package/dist/store/sqlite/finance.d.ts +40 -2
- package/dist/store/sqlite/finance.js +48 -42
- package/dist/store/sqlite/knowledge.js +2 -1
- package/dist/store/sqlite/mail-findings.js +81 -1
- package/dist/store/sqlite/manifest.js +2 -0
- package/dist/store/sqlite/migrations.generated.js +20 -0
- package/dist/store/sqlite/orders.d.ts +84 -0
- package/dist/store/sqlite/orders.js +186 -0
- package/dist/store/sqlite/ref-summaries.js +10 -0
- package/dist/store/sqlite/schema.d.ts +440 -0
- package/dist/store/sqlite/schema.js +38 -0
- package/dist/store/sqlite/things.js +1 -0
- package/dist/store/store.d.ts +3 -0
- package/dist/store/store.js +5 -0
- package/package.json +1 -1
package/dist/mail/findings.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { INVOICE_DIRECTIONS, MONEY_DIRECTIONS } from "../domain/finance.js";
|
|
2
3
|
import { MEMORY_KINDS, MEMORY_SCOPES } from "../domain/knowledge.js";
|
|
3
4
|
import { formatLocator, parseLocator } from "../domain/locator.js";
|
|
5
|
+
import { ORDER_PURPOSES, ORDER_STATUSES } from "../domain/orders.js";
|
|
4
6
|
import { parseReference } from "../domain/references.js";
|
|
5
7
|
import { findingFields, findingHash, findingId, findingText, meetingTimestamp, validateBatch, validateFinding, validEvidence, } from "../meetings/index.js";
|
|
6
8
|
export { MAIL_FINDINGS_KIND } from "../store/mail-findings.js";
|
|
@@ -42,6 +44,18 @@ const LINKS = {
|
|
|
42
44
|
"chat:email": "project",
|
|
43
45
|
};
|
|
44
46
|
const ADDRESS = /^[^\s@<>]+@[^\s@<>]+$/;
|
|
47
|
+
const PARTY = /^(person|organization):[1-9]\d*$/;
|
|
48
|
+
const INVOICE = /^invoice:[1-9]\d*$/;
|
|
49
|
+
const PAYMENT = /^payment:[1-9]\d*$/;
|
|
50
|
+
const PROJECT = /^project:[1-9]\d*$/;
|
|
51
|
+
const oneOf = (value, allowed, what) => {
|
|
52
|
+
if (typeof value !== "string" || !allowed.includes(value))
|
|
53
|
+
return invalid(`${what} is ${allowed.join(", ")}`);
|
|
54
|
+
return value;
|
|
55
|
+
};
|
|
56
|
+
/** Money and dates stay text here: the store's finance and order services check them as they check the owner's. */
|
|
57
|
+
const word = (value, limit) => findingText(value, limit);
|
|
58
|
+
const optional = (key, value, read) => (value === undefined ? {} : { [key]: read(value) });
|
|
45
59
|
const one = (raw, ids) => {
|
|
46
60
|
if (!raw || typeof raw !== "object")
|
|
47
61
|
return invalid("a finding is an object");
|
|
@@ -123,8 +137,157 @@ const one = (raw, ids) => {
|
|
|
123
137
|
evidence,
|
|
124
138
|
};
|
|
125
139
|
}
|
|
140
|
+
case "invoice": {
|
|
141
|
+
findingFields(raw, [
|
|
142
|
+
"id",
|
|
143
|
+
"kind",
|
|
144
|
+
"direction",
|
|
145
|
+
"counterparty",
|
|
146
|
+
"currency",
|
|
147
|
+
"lines",
|
|
148
|
+
"total",
|
|
149
|
+
"number",
|
|
150
|
+
"issueDate",
|
|
151
|
+
"dueDate",
|
|
152
|
+
"project",
|
|
153
|
+
"reason",
|
|
154
|
+
"evidence",
|
|
155
|
+
], RULES.singular);
|
|
156
|
+
if (!Array.isArray(raw.lines) || raw.lines.length < 1 || raw.lines.length > 50)
|
|
157
|
+
return invalid("an invoice finding has 1–50 lines");
|
|
158
|
+
const lines = raw.lines.map((line) => {
|
|
159
|
+
if (!line || typeof line !== "object")
|
|
160
|
+
return invalid("an invoice line is an object");
|
|
161
|
+
const fields = line;
|
|
162
|
+
findingFields(fields, ["description", "quantity", "unitPrice", "amount"], "invoice line");
|
|
163
|
+
return {
|
|
164
|
+
description: word(fields.description, 500),
|
|
165
|
+
...optional("quantity", fields.quantity, (value) => word(value, 20)),
|
|
166
|
+
...optional("unitPrice", fields.unitPrice, (value) => word(value, 30)),
|
|
167
|
+
...optional("amount", fields.amount, (value) => word(value, 30)),
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
return {
|
|
171
|
+
id,
|
|
172
|
+
kind: "invoice",
|
|
173
|
+
direction: oneOf(raw.direction, INVOICE_DIRECTIONS, "an invoice's direction"),
|
|
174
|
+
counterparty: reference(raw.counterparty, PARTY, "an invoice's counterparty"),
|
|
175
|
+
currency: word(raw.currency, 3),
|
|
176
|
+
lines,
|
|
177
|
+
...optional("total", raw.total, (value) => word(value, 30)),
|
|
178
|
+
...optional("number", raw.number, (value) => word(value, 80)),
|
|
179
|
+
...optional("issueDate", raw.issueDate, (value) => word(value, 10)),
|
|
180
|
+
...optional("dueDate", raw.dueDate, (value) => word(value, 10)),
|
|
181
|
+
...optional("project", raw.project, (value) => reference(value, PROJECT, "a project")),
|
|
182
|
+
reason: findingText(raw.reason, 2000),
|
|
183
|
+
evidence,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
case "payment": {
|
|
187
|
+
findingFields(raw, [
|
|
188
|
+
"id",
|
|
189
|
+
"kind",
|
|
190
|
+
"direction",
|
|
191
|
+
"payer",
|
|
192
|
+
"payee",
|
|
193
|
+
"amount",
|
|
194
|
+
"currency",
|
|
195
|
+
"date",
|
|
196
|
+
"reference",
|
|
197
|
+
"allocations",
|
|
198
|
+
"project",
|
|
199
|
+
"reason",
|
|
200
|
+
"evidence",
|
|
201
|
+
], RULES.singular);
|
|
202
|
+
let allocations;
|
|
203
|
+
if (raw.allocations !== undefined) {
|
|
204
|
+
if (!Array.isArray(raw.allocations) || raw.allocations.length < 1 || raw.allocations.length > 8)
|
|
205
|
+
return invalid("a payment finding's allocations are 1–8 invoices it settles");
|
|
206
|
+
allocations = raw.allocations.map((share) => {
|
|
207
|
+
if (!share || typeof share !== "object")
|
|
208
|
+
return invalid("an allocation is an invoice and an amount");
|
|
209
|
+
const fields = share;
|
|
210
|
+
findingFields(fields, ["invoice", "amount"], "allocation");
|
|
211
|
+
return {
|
|
212
|
+
invoice: reference(fields.invoice, INVOICE, "an allocation's invoice"),
|
|
213
|
+
amount: word(fields.amount, 30),
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
id,
|
|
219
|
+
kind: "payment",
|
|
220
|
+
direction: oneOf(raw.direction, MONEY_DIRECTIONS, "a payment's direction"),
|
|
221
|
+
...optional("payer", raw.payer, (value) => reference(value, PARTY, "a payment's payer")),
|
|
222
|
+
...optional("payee", raw.payee, (value) => reference(value, PARTY, "a payment's payee")),
|
|
223
|
+
amount: word(raw.amount, 30),
|
|
224
|
+
currency: word(raw.currency, 3),
|
|
225
|
+
date: word(raw.date, 10),
|
|
226
|
+
...optional("reference", raw.reference, (value) => word(value, 500)),
|
|
227
|
+
...(allocations === undefined ? {} : { allocations }),
|
|
228
|
+
...optional("project", raw.project, (value) => reference(value, PROJECT, "a project")),
|
|
229
|
+
reason: findingText(raw.reason, 2000),
|
|
230
|
+
evidence,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
case "order": {
|
|
234
|
+
findingFields(raw, [
|
|
235
|
+
"id",
|
|
236
|
+
"kind",
|
|
237
|
+
"purpose",
|
|
238
|
+
"customer",
|
|
239
|
+
"supplier",
|
|
240
|
+
"supplierName",
|
|
241
|
+
"number",
|
|
242
|
+
"total",
|
|
243
|
+
"currency",
|
|
244
|
+
"status",
|
|
245
|
+
"orderDate",
|
|
246
|
+
"fulfilledDate",
|
|
247
|
+
"invoice",
|
|
248
|
+
"payment",
|
|
249
|
+
"project",
|
|
250
|
+
"reason",
|
|
251
|
+
"evidence",
|
|
252
|
+
], RULES.singular);
|
|
253
|
+
return {
|
|
254
|
+
id,
|
|
255
|
+
kind: "order",
|
|
256
|
+
purpose: oneOf(raw.purpose, ORDER_PURPOSES, "an order's purpose"),
|
|
257
|
+
...optional("customer", raw.customer, (value) => reference(value, PARTY, "an order's customer")),
|
|
258
|
+
...optional("supplier", raw.supplier, (value) => reference(value, PARTY, "an order's supplier")),
|
|
259
|
+
supplierName: word(raw.supplierName, 200),
|
|
260
|
+
...optional("number", raw.number, (value) => word(value, 80)),
|
|
261
|
+
...optional("total", raw.total, (value) => word(value, 30)),
|
|
262
|
+
...optional("currency", raw.currency, (value) => word(value, 3)),
|
|
263
|
+
...optional("status", raw.status, (value) => oneOf(value, ORDER_STATUSES, "an order's status")),
|
|
264
|
+
...optional("orderDate", raw.orderDate, (value) => word(value, 10)),
|
|
265
|
+
...optional("fulfilledDate", raw.fulfilledDate, (value) => word(value, 10)),
|
|
266
|
+
...optional("invoice", raw.invoice, (value) => reference(value, INVOICE, "an order's invoice")),
|
|
267
|
+
...optional("payment", raw.payment, (value) => reference(value, PAYMENT, "an order's payment")),
|
|
268
|
+
...optional("project", raw.project, (value) => reference(value, PROJECT, "a project")),
|
|
269
|
+
reason: findingText(raw.reason, 2000),
|
|
270
|
+
evidence,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
case "order_update": {
|
|
274
|
+
findingFields(raw, ["id", "kind", "supplierName", "number", "status", "date", "reason", "evidence"], RULES.singular);
|
|
275
|
+
const status = oneOf(raw.status, ORDER_STATUSES, "an order update's status");
|
|
276
|
+
if (raw.date !== undefined && status !== "delivered")
|
|
277
|
+
return invalid("only a delivered order update gives date");
|
|
278
|
+
return {
|
|
279
|
+
id,
|
|
280
|
+
kind: "order_update",
|
|
281
|
+
supplierName: word(raw.supplierName, 200),
|
|
282
|
+
number: word(raw.number, 80),
|
|
283
|
+
status,
|
|
284
|
+
...optional("date", raw.date, (value) => word(value, 10)),
|
|
285
|
+
reason: findingText(raw.reason, 2000),
|
|
286
|
+
evidence,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
126
289
|
default:
|
|
127
|
-
return invalid("a mail finding is a task, task_update, fact, memory, link or
|
|
290
|
+
return invalid("a mail finding is a task, task_update, fact, memory, link, new_contact, invoice, payment, order or order_update");
|
|
128
291
|
}
|
|
129
292
|
};
|
|
130
293
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { MAIL_FINDINGS_KIND } from "../../../store/index.js";
|
|
2
2
|
import { kept, proposerOfAuthor } from "./shared.js";
|
|
3
|
+
const words = (...parts) => parts.filter(Boolean).join(" ");
|
|
3
4
|
/** A finding's one line in the inbox; never the email's text, only what the agent wrote. */
|
|
4
5
|
const summaryOf = (finding) => {
|
|
5
6
|
switch (finding.kind) {
|
|
@@ -15,6 +16,14 @@ const summaryOf = (finding) => {
|
|
|
15
16
|
return `${finding.from} → ${finding.to}`;
|
|
16
17
|
case "new_contact":
|
|
17
18
|
return `${finding.name} <${finding.address}>`;
|
|
19
|
+
case "invoice":
|
|
20
|
+
return words("invoice", finding.number, finding.total, finding.currency, `· ${finding.reason}`);
|
|
21
|
+
case "payment":
|
|
22
|
+
return words("payment", finding.amount, finding.currency, finding.date, `· ${finding.reason}`);
|
|
23
|
+
case "order":
|
|
24
|
+
return words("order", finding.number, finding.supplierName, finding.status ?? "placed", `· ${finding.reason}`);
|
|
25
|
+
case "order_update":
|
|
26
|
+
return words("order", finding.number, finding.supplierName, finding.status, `· ${finding.reason}`);
|
|
18
27
|
}
|
|
19
28
|
};
|
|
20
29
|
const rowOf = (batch, createdAt) => {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { MessageStore } from "../../../store/index.js";
|
|
2
|
+
import type { ProposalKind } from "../types.js";
|
|
3
|
+
/** An agent's order, by its own ref (`order:12`) as invoices are; only the owner decides it. */
|
|
4
|
+
export declare const orderKind: (store: Pick<MessageStore, "orders">) => ProposalKind;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { kept, proposerOfAuthor, pushed } from "./shared.js";
|
|
2
|
+
const rowOf = (order) => ({
|
|
3
|
+
id: order.ref,
|
|
4
|
+
kind: "order",
|
|
5
|
+
status: order.review,
|
|
6
|
+
project: order.project,
|
|
7
|
+
subject: order.ref,
|
|
8
|
+
object: order.supplier?.ref ?? null,
|
|
9
|
+
summary: order.title,
|
|
10
|
+
proposedBy: proposerOfAuthor(order.author),
|
|
11
|
+
reason: order.reason,
|
|
12
|
+
evidence: [],
|
|
13
|
+
score: null,
|
|
14
|
+
signals: null,
|
|
15
|
+
parts: null,
|
|
16
|
+
createdAt: order.createdAt,
|
|
17
|
+
});
|
|
18
|
+
/** An agent's order, by its own ref (`order:12`) as invoices are; only the owner decides it. */
|
|
19
|
+
export const orderKind = (store) => ({
|
|
20
|
+
kind: "order",
|
|
21
|
+
prefix: "order",
|
|
22
|
+
list: async (filter) => {
|
|
23
|
+
const status = filter.status ?? "proposed";
|
|
24
|
+
const orders = await store.orders.list({ ...pushed(filter), ...(status === "all" ? {} : { review: status }) });
|
|
25
|
+
return kept(orders.map(rowOf), filter);
|
|
26
|
+
},
|
|
27
|
+
get: async (id) => rowOf(await store.orders.get(id)),
|
|
28
|
+
decide: async (id, { confirm, by, reason }) => {
|
|
29
|
+
const order = confirm ? await store.orders.confirm(id, by, reason) : await store.orders.reject(id, by, reason);
|
|
30
|
+
return { id, kind: "order", status: confirm ? "confirmed" : "rejected", made: confirm ? [order.ref] : [] };
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -8,6 +8,7 @@ import { keyDocumentKind } from "./kinds/key-document.js";
|
|
|
8
8
|
import { mailFindingsKind } from "./kinds/mail-findings.js";
|
|
9
9
|
import { meetingFindingsKind } from "./kinds/meeting-findings.js";
|
|
10
10
|
import { memoryKind } from "./kinds/memory.js";
|
|
11
|
+
import { orderKind } from "./kinds/orders.js";
|
|
11
12
|
import { relationshipKind } from "./kinds/relationship.js";
|
|
12
13
|
import { relationshipUpdateKind } from "./kinds/relationship-update.js";
|
|
13
14
|
import { taskCloseKind } from "./kinds/task-close.js";
|
|
@@ -132,6 +133,7 @@ export const proposalRegistry = (store) => {
|
|
|
132
133
|
factKind(store),
|
|
133
134
|
invoiceKind(store),
|
|
134
135
|
paymentKind(store),
|
|
136
|
+
orderKind(store),
|
|
135
137
|
memoryKind(store),
|
|
136
138
|
findingsKind(store),
|
|
137
139
|
mailFindingsKind(store),
|
package/dist/store/index.d.ts
CHANGED
|
@@ -53,6 +53,7 @@ export type { Author, MemoriesStore, Memory, MemoryInput } from "./sqlite/memori
|
|
|
53
53
|
export type { NoteIndexState } from "./sqlite/note-index.js";
|
|
54
54
|
export { type NearestNote, NOTE_FIELDS, type NoteChunkToEmbed, type NoteHit, type NoteQuery, type NoteSearch, } from "./sqlite/note-search.js";
|
|
55
55
|
export type { FileNoteInput, Link, LinkInput, LinkListFilter, Note, NoteFolder, NotesStore, NoteTag, } from "./sqlite/notes.js";
|
|
56
|
+
export type { Order, OrderFilter, OrderInput, OrdersStore } from "./sqlite/orders.js";
|
|
56
57
|
export type { PrivateContact, PrivateContactNote } from "./sqlite/private-people.js";
|
|
57
58
|
export { assertGenericAction, PROPOSAL_STATUSES, type ProposalInput, type ProposedAction, type ProposedActionsStore, proposerOfAction, } from "./sqlite/proposed-actions.js";
|
|
58
59
|
export type { RecordKey, RecordOutcome, RecordStore, RecordsWritten, SourceRecord, } from "./sqlite/records.js";
|
|
@@ -27,6 +27,7 @@ export declare const ENTITY_TABLES: {
|
|
|
27
27
|
readonly memory: "memories";
|
|
28
28
|
readonly message: "messages";
|
|
29
29
|
readonly note: "notes";
|
|
30
|
+
readonly order: "orders";
|
|
30
31
|
readonly organization: "organizations";
|
|
31
32
|
readonly payment: "payments";
|
|
32
33
|
readonly person: "persons";
|
|
@@ -11,7 +11,7 @@ export interface Party {
|
|
|
11
11
|
/** The owner's own person row. */
|
|
12
12
|
owner: boolean;
|
|
13
13
|
}
|
|
14
|
-
interface Reviewed {
|
|
14
|
+
export interface Reviewed {
|
|
15
15
|
review: FinanceReview;
|
|
16
16
|
/** `name` is a bot's handle and `kind` whether it is an agent or a rule; both `null` for a person. */
|
|
17
17
|
author: {
|
|
@@ -91,7 +91,7 @@ export interface Payment extends Reviewed {
|
|
|
91
91
|
export interface PaymentDetail extends Payment {
|
|
92
92
|
allocations: Allocation[];
|
|
93
93
|
}
|
|
94
|
-
interface Written {
|
|
94
|
+
export interface Written {
|
|
95
95
|
/** The owner's entry is confirmed at once; a bot's waits as proposed for the owner. */
|
|
96
96
|
by: Author;
|
|
97
97
|
project?: string;
|
|
@@ -204,6 +204,44 @@ export interface FinanceStore {
|
|
|
204
204
|
payable: OpenTotal[];
|
|
205
205
|
}>;
|
|
206
206
|
}
|
|
207
|
+
type Row = Record<string, unknown>;
|
|
208
|
+
export declare const refuse: (message: string, reason: string, code?: "validation_error" | "permission_error") => never;
|
|
209
|
+
export declare const oneOf: <T extends string>(value: unknown, allowed: readonly T[], what: string) => T;
|
|
210
|
+
export declare const text: (value: unknown, what: string, limit: number) => string | null;
|
|
211
|
+
export declare const isoDate: (value: unknown, what: string) => string | null;
|
|
212
|
+
export declare const iso: (value: unknown) => string;
|
|
213
|
+
/** What invoices, payments and orders share: their parties, who wrote them, and how the owner decides them. */
|
|
214
|
+
export declare const reviewedRowsOver: (context: StoreContext) => {
|
|
215
|
+
partyOf: (type: unknown, id: unknown) => Party | null;
|
|
216
|
+
partyThing: (reference: string | undefined, what: string) => import("./things.js").Thing | undefined;
|
|
217
|
+
reviewedOf: (row: Row, type: "invoice" | "payment" | "order") => Reviewed;
|
|
218
|
+
written: (input: Written) => {
|
|
219
|
+
owner: boolean;
|
|
220
|
+
actor: Actor;
|
|
221
|
+
model: string | null;
|
|
222
|
+
source: string;
|
|
223
|
+
review: string;
|
|
224
|
+
confirmedType: "bot" | "person" | null;
|
|
225
|
+
confirmedId: number | null;
|
|
226
|
+
metadata: string | null;
|
|
227
|
+
project: number | null;
|
|
228
|
+
evidence: string[];
|
|
229
|
+
};
|
|
230
|
+
keepEvidence: (type: "invoice" | "payment" | "order", id: number, by: ReturnType<(input: Written) => {
|
|
231
|
+
owner: boolean;
|
|
232
|
+
actor: Actor;
|
|
233
|
+
model: string | null;
|
|
234
|
+
source: string;
|
|
235
|
+
review: string;
|
|
236
|
+
confirmedType: "bot" | "person" | null;
|
|
237
|
+
confirmedId: number | null;
|
|
238
|
+
metadata: string | null;
|
|
239
|
+
project: number | null;
|
|
240
|
+
evidence: string[];
|
|
241
|
+
}>) => void;
|
|
242
|
+
ownerOnly: (by: Author, what: string) => void;
|
|
243
|
+
keepDecision: (table: "invoices" | "payments" | "orders", id: number, by: Decider, reason?: string) => void;
|
|
244
|
+
};
|
|
207
245
|
type FinanceOperations = {
|
|
208
246
|
[K in keyof FinanceStore]: FinanceStore[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => R : never;
|
|
209
247
|
};
|
|
@@ -6,22 +6,22 @@ import { proposedBySql, proposerOf } from "./actors.js";
|
|
|
6
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
|
-
const refuse = (message, reason, code = "validation_error") => {
|
|
9
|
+
export const refuse = (message, reason, code = "validation_error") => {
|
|
10
10
|
throw new CliError(code, message, { reason });
|
|
11
11
|
};
|
|
12
|
-
const oneOf = (value, allowed, what) => {
|
|
12
|
+
export const oneOf = (value, allowed, what) => {
|
|
13
13
|
if (typeof value !== "string" || !allowed.includes(value))
|
|
14
14
|
return refuse(`${what} is one of ${allowed.join(", ")}`, "finance.field");
|
|
15
15
|
return value;
|
|
16
16
|
};
|
|
17
|
-
const text = (value, what, limit) => {
|
|
17
|
+
export const text = (value, what, limit) => {
|
|
18
18
|
if (value === undefined || value === null)
|
|
19
19
|
return null;
|
|
20
20
|
if (typeof value !== "string" || !value.trim() || value.length > limit || value.includes("\0"))
|
|
21
21
|
return refuse(`${what} takes 1–${limit} characters`, "finance.field");
|
|
22
22
|
return value.trim();
|
|
23
23
|
};
|
|
24
|
-
const isoDate = (value, what) => {
|
|
24
|
+
export const isoDate = (value, what) => {
|
|
25
25
|
if (value === undefined || value === null)
|
|
26
26
|
return null;
|
|
27
27
|
const date = typeof value === "string" ? value.trim() : "";
|
|
@@ -29,7 +29,7 @@ const isoDate = (value, what) => {
|
|
|
29
29
|
return refuse(`${what} is a date such as 2026-11-15`, "finance.field");
|
|
30
30
|
return date;
|
|
31
31
|
};
|
|
32
|
-
const iso = (value) => new Date(Number(value)).toISOString();
|
|
32
|
+
export const iso = (value) => new Date(Number(value)).toISOString();
|
|
33
33
|
const evidenceList = (value) => {
|
|
34
34
|
if (value === undefined)
|
|
35
35
|
return [];
|
|
@@ -56,8 +56,9 @@ const INVOICES = `SELECT i.*, ${PAID} AS paid, b.name AS author_name, b.kind AS
|
|
|
56
56
|
"LEFT JOIN bots b ON i.author_type = 'bot' AND b.id = i.author_id";
|
|
57
57
|
const PAYMENTS = "SELECT p.*, (SELECT coalesce(sum(amount), 0) FROM payment_allocations WHERE payment_id = p.id) AS allocated, " +
|
|
58
58
|
"b.name AS author_name, b.kind AS author_kind FROM payments p LEFT JOIN bots b ON p.author_type = 'bot' AND b.id = p.author_id";
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
/** What invoices, payments and orders share: their parties, who wrote them, and how the owner decides them. */
|
|
60
|
+
export const reviewedRowsOver = (context) => {
|
|
61
|
+
const { database } = context;
|
|
61
62
|
const partyOf = (type, id) => {
|
|
62
63
|
if (type == null)
|
|
63
64
|
return null;
|
|
@@ -86,6 +87,46 @@ export const financeOperationsOver = (context) => {
|
|
|
86
87
|
createdAt: iso(row.created_at),
|
|
87
88
|
updatedAt: iso(row.updated_at),
|
|
88
89
|
});
|
|
90
|
+
const written = (input) => {
|
|
91
|
+
const owner = input.by === "owner";
|
|
92
|
+
const { actor, model } = authorActor(context, input.by);
|
|
93
|
+
const source = owner ? "owner" : (text(input.source, "a source", 80) ?? "agent");
|
|
94
|
+
const reason = owner ? null : text(input.reason, "a reason", 2000);
|
|
95
|
+
return {
|
|
96
|
+
owner,
|
|
97
|
+
actor,
|
|
98
|
+
model,
|
|
99
|
+
source,
|
|
100
|
+
review: owner ? "confirmed" : "proposed",
|
|
101
|
+
confirmedType: owner ? actor.type : null,
|
|
102
|
+
confirmedId: owner ? actor.id : null,
|
|
103
|
+
metadata: reason === null ? null : JSON.stringify({ reason }),
|
|
104
|
+
project: input.project === undefined ? null : projectId(database, input.project),
|
|
105
|
+
evidence: evidenceList(input.evidence),
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
const keepEvidence = (type, id, by) => {
|
|
109
|
+
if (by.evidence.length)
|
|
110
|
+
linkEvidence(context, { type, id }, by.evidence, by.owner ? "owner" : "agent");
|
|
111
|
+
};
|
|
112
|
+
const ownerOnly = (by, what) => {
|
|
113
|
+
if (by !== "owner")
|
|
114
|
+
refuse(`only the owner ${what}`, "finance.owner-only", "permission_error");
|
|
115
|
+
};
|
|
116
|
+
const keepDecision = (table, id, by, reason) => {
|
|
117
|
+
const why = [by === "owner" ? undefined : by.reason.trim(), reason?.trim() || undefined].filter(Boolean).join(": ");
|
|
118
|
+
if (by === "owner" && !why)
|
|
119
|
+
return;
|
|
120
|
+
const decision = by === "owner" ? { by: "owner", reason: why } : { by: "agent", bot: by.bot, reason: why };
|
|
121
|
+
database
|
|
122
|
+
.prepare(`UPDATE ${table} SET metadata = json_set(coalesce(metadata, '{}'), '$.decision', json(?)) WHERE id = ?`)
|
|
123
|
+
.run(JSON.stringify(decision), id);
|
|
124
|
+
};
|
|
125
|
+
return { partyOf, partyThing, reviewedOf, written, keepEvidence, ownerOnly, keepDecision };
|
|
126
|
+
};
|
|
127
|
+
export const financeOperationsOver = (context) => {
|
|
128
|
+
const { database, now } = context;
|
|
129
|
+
const { partyOf, partyThing, reviewedOf, written, keepEvidence, ownerOnly, keepDecision } = reviewedRowsOver(context);
|
|
89
130
|
const invoiceOf = (row) => {
|
|
90
131
|
const currency = String(row.currency);
|
|
91
132
|
const scale = scaleOf(currency);
|
|
@@ -229,41 +270,6 @@ export const financeOperationsOver = (context) => {
|
|
|
229
270
|
.run(Number(payment.id), Number(invoice.id), units, actor.type, actor.id, at, at);
|
|
230
271
|
return Number(invoice.id);
|
|
231
272
|
};
|
|
232
|
-
const written = (input) => {
|
|
233
|
-
const owner = input.by === "owner";
|
|
234
|
-
const { actor, model } = authorActor(context, input.by);
|
|
235
|
-
const source = owner ? "owner" : (text(input.source, "a source", 80) ?? "agent");
|
|
236
|
-
const reason = owner ? null : text(input.reason, "a reason", 2000);
|
|
237
|
-
return {
|
|
238
|
-
owner,
|
|
239
|
-
actor,
|
|
240
|
-
model,
|
|
241
|
-
source,
|
|
242
|
-
review: owner ? "confirmed" : "proposed",
|
|
243
|
-
confirmedType: owner ? actor.type : null,
|
|
244
|
-
confirmedId: owner ? actor.id : null,
|
|
245
|
-
metadata: reason === null ? null : JSON.stringify({ reason }),
|
|
246
|
-
project: input.project === undefined ? null : projectId(database, input.project),
|
|
247
|
-
evidence: evidenceList(input.evidence),
|
|
248
|
-
};
|
|
249
|
-
};
|
|
250
|
-
const keepEvidence = (type, id, by) => {
|
|
251
|
-
if (by.evidence.length)
|
|
252
|
-
linkEvidence(context, { type, id }, by.evidence, by.owner ? "owner" : "agent");
|
|
253
|
-
};
|
|
254
|
-
const ownerOnly = (by, what) => {
|
|
255
|
-
if (by !== "owner")
|
|
256
|
-
refuse(`only the owner ${what}`, "finance.owner-only", "permission_error");
|
|
257
|
-
};
|
|
258
|
-
const keepDecision = (table, id, by, reason) => {
|
|
259
|
-
const why = [by === "owner" ? undefined : by.reason.trim(), reason?.trim() || undefined].filter(Boolean).join(": ");
|
|
260
|
-
if (by === "owner" && !why)
|
|
261
|
-
return;
|
|
262
|
-
const decision = by === "owner" ? { by: "owner", reason: why } : { by: "agent", bot: by.bot, reason: why };
|
|
263
|
-
database
|
|
264
|
-
.prepare(`UPDATE ${table} SET metadata = json_set(coalesce(metadata, '{}'), '$.decision', json(?)) WHERE id = ?`)
|
|
265
|
-
.run(JSON.stringify(decision), id);
|
|
266
|
-
};
|
|
267
273
|
/** A proposed payment's allocations count once confirmed; by then other confirmed payments may cover the invoice. */
|
|
268
274
|
const coveredElsewhere = (paymentId) => {
|
|
269
275
|
const over = database
|
|
@@ -150,7 +150,8 @@ export const knowledgeStoreOver = (context) => {
|
|
|
150
150
|
parsed.type === "event" ||
|
|
151
151
|
parsed.type === "meeting" ||
|
|
152
152
|
parsed.type === "invoice" ||
|
|
153
|
-
parsed.type === "payment"
|
|
153
|
+
parsed.type === "payment" ||
|
|
154
|
+
parsed.type === "order")
|
|
154
155
|
return undefined;
|
|
155
156
|
return { type: parsed.type, id: parsed.id };
|
|
156
157
|
};
|
|
@@ -5,9 +5,11 @@ import { MAIL_FINDINGS_PER_SENDER, mailFindingFingerprint, validateMailFindings,
|
|
|
5
5
|
import { findingHash } from "../../meetings/index.js";
|
|
6
6
|
import { assertMayDecide } from "../decisions.js";
|
|
7
7
|
import { MAIL_FINDINGS_KIND } from "../mail-findings.js";
|
|
8
|
+
import { financeOperationsOver } from "./finance.js";
|
|
8
9
|
import { findingsAgent, findingsCoreOver } from "./findings-core.js";
|
|
9
|
-
import { authorActor, memoriesStoreOver } from "./memories.js";
|
|
10
|
+
import { authorActor, linkEvidence, memoriesStoreOver } from "./memories.js";
|
|
10
11
|
import { insertLink, requiredThing } from "./notes.js";
|
|
12
|
+
import { orderOfShop, orderOperationsOver } from "./orders.js";
|
|
11
13
|
import { linkIdentityToPerson } from "./person-links.js";
|
|
12
14
|
import { proposalOperationsOver } from "./proposed-actions.js";
|
|
13
15
|
import { addComment } from "./task-comments.js";
|
|
@@ -35,6 +37,8 @@ export const mailFindingsStoreOver = (context) => {
|
|
|
35
37
|
const core = findingsCoreOver(context, MAIL_FINDINGS_KIND);
|
|
36
38
|
const memories = memoriesStoreOver(context);
|
|
37
39
|
const tasks = workTaskOperationsOver(context);
|
|
40
|
+
const finance = financeOperationsOver(context);
|
|
41
|
+
const orders = orderOperationsOver(context);
|
|
38
42
|
const mailbox = (key) => {
|
|
39
43
|
if (key?.provider !== "email" || typeof key.account !== "string" || !key.account.trim())
|
|
40
44
|
return invalid("mail findings belong to one email account");
|
|
@@ -135,6 +139,60 @@ export const mailFindingsStoreOver = (context) => {
|
|
|
135
139
|
}
|
|
136
140
|
return read;
|
|
137
141
|
};
|
|
142
|
+
/** The invoice, payment or order an accepted finding records: the owner's own entry, citing the mail it came from. */
|
|
143
|
+
const entry = (finding, evidence) => {
|
|
144
|
+
const by = "owner";
|
|
145
|
+
switch (finding.kind) {
|
|
146
|
+
case "invoice":
|
|
147
|
+
return () => finance.addInvoice({
|
|
148
|
+
by,
|
|
149
|
+
evidence,
|
|
150
|
+
direction: finding.direction,
|
|
151
|
+
counterparty: finding.counterparty,
|
|
152
|
+
currency: finding.currency,
|
|
153
|
+
lines: finding.lines,
|
|
154
|
+
...(finding.total === undefined ? {} : { total: finding.total }),
|
|
155
|
+
...(finding.number === undefined ? {} : { number: finding.number }),
|
|
156
|
+
...(finding.issueDate === undefined ? {} : { issueDate: finding.issueDate }),
|
|
157
|
+
...(finding.dueDate === undefined ? {} : { dueDate: finding.dueDate }),
|
|
158
|
+
...(finding.project === undefined ? {} : { project: finding.project }),
|
|
159
|
+
});
|
|
160
|
+
case "payment":
|
|
161
|
+
return () => finance.addPayment({
|
|
162
|
+
by,
|
|
163
|
+
evidence,
|
|
164
|
+
direction: finding.direction,
|
|
165
|
+
amount: finding.amount,
|
|
166
|
+
currency: finding.currency,
|
|
167
|
+
date: finding.date,
|
|
168
|
+
...(finding.payer === undefined ? {} : { payer: finding.payer }),
|
|
169
|
+
...(finding.payee === undefined ? {} : { payee: finding.payee }),
|
|
170
|
+
...(finding.reference === undefined ? {} : { reference: finding.reference }),
|
|
171
|
+
...(finding.allocations === undefined ? {} : { allocations: finding.allocations }),
|
|
172
|
+
...(finding.project === undefined ? {} : { project: finding.project }),
|
|
173
|
+
});
|
|
174
|
+
case "order": {
|
|
175
|
+
const { id: _id, kind: _kind, evidence: _cited, reason: _reason, ...fields } = finding;
|
|
176
|
+
return () => orders.add({ ...fields, by, evidence });
|
|
177
|
+
}
|
|
178
|
+
default:
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
/** Runs what accepting would write and rolls it back, so staging refuses what the owner's accept would refuse. */
|
|
183
|
+
const ROLLBACK = Symbol("rollback");
|
|
184
|
+
const wouldWrite = (make) => {
|
|
185
|
+
try {
|
|
186
|
+
write(database, () => {
|
|
187
|
+
make();
|
|
188
|
+
throw ROLLBACK;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (error !== ROLLBACK)
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
138
196
|
const identityOfAddress = (address) => database
|
|
139
197
|
.prepare("SELECT i.id, l.person_id FROM identities i LEFT JOIN identity_links l ON l.identity_id = i.id " +
|
|
140
198
|
"WHERE i.provider = 'email' AND lower(i.external_id) = ?")
|
|
@@ -163,6 +221,14 @@ export const mailFindingsStoreOver = (context) => {
|
|
|
163
221
|
thing(finding.from, "the link's source");
|
|
164
222
|
thing(finding.to, "the link's target");
|
|
165
223
|
return;
|
|
224
|
+
case "invoice":
|
|
225
|
+
case "payment":
|
|
226
|
+
case "order":
|
|
227
|
+
wouldWrite(entry(finding, finding.evidence.map((one) => one.reference)));
|
|
228
|
+
return;
|
|
229
|
+
case "order_update":
|
|
230
|
+
orderOfShop(context, finding.supplierName, finding.number);
|
|
231
|
+
return;
|
|
166
232
|
case "new_contact": {
|
|
167
233
|
const ids = finding.evidence.map(({ reference }) => Number(read.get(reference)?.id));
|
|
168
234
|
const marks = ids.map(() => "?").join(",");
|
|
@@ -230,6 +296,20 @@ export const mailFindingsStoreOver = (context) => {
|
|
|
230
296
|
database.prepare("UPDATE links SET confirmed = 1, updated_at = ? WHERE id = ?").run(now(), Number(link.id));
|
|
231
297
|
return `link:${link.id}`;
|
|
232
298
|
}
|
|
299
|
+
case "invoice":
|
|
300
|
+
case "payment":
|
|
301
|
+
case "order":
|
|
302
|
+
return entry(finding, evidence)().ref;
|
|
303
|
+
case "order_update": {
|
|
304
|
+
const order = orderOfShop(context, finding.supplierName, finding.number);
|
|
305
|
+
orders.setStatus(order, {
|
|
306
|
+
status: finding.status,
|
|
307
|
+
by: "owner",
|
|
308
|
+
...(finding.date === undefined ? {} : { date: finding.date }),
|
|
309
|
+
});
|
|
310
|
+
linkEvidence(context, { type: "order", id: Number(order.slice("order:".length)) }, evidence, "owner");
|
|
311
|
+
return order;
|
|
312
|
+
}
|
|
233
313
|
case "new_contact": {
|
|
234
314
|
const person = Number(identityOfAddress(finding.address)?.person_id);
|
|
235
315
|
database.prepare("UPDATE persons SET name = ?, updated_at = ? WHERE id = ?").run(finding.name, now(), person);
|
|
@@ -65,6 +65,8 @@ export const MANIFEST = [
|
|
|
65
65
|
{ name: "20261102192959_version-47-records", version: 47, minCompatible: 34 },
|
|
66
66
|
{ name: "20261102222340_version-48-test-accounts", version: 48, minCompatible: 34 },
|
|
67
67
|
{ name: "20261104232153_version-49-relation-amounts", version: 49, minCompatible: 34 },
|
|
68
|
+
{ name: "20261105125314_version-50-orders", version: 50, minCompatible: 34 },
|
|
69
|
+
{ name: "20261105125315_version-50-orders-rules", version: 50, minCompatible: 34 },
|
|
68
70
|
];
|
|
69
71
|
export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
|
|
70
72
|
const migrations = [];
|