@wirecat/cli-core 0.573.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/botcheck/registries.js +10 -7
  3. package/dist/calendar/feeds.js +7 -3
  4. package/dist/calendar/mcp/index.d.ts +12 -12
  5. package/dist/cli/findings-input.js +6 -0
  6. package/dist/cli/messenger/serve-catch-up.js +3 -2
  7. package/dist/domain/orders.d.ts +19 -0
  8. package/dist/domain/orders.js +12 -0
  9. package/dist/domain/ref-summaries.js +1 -0
  10. package/dist/domain/references.d.ts +1 -1
  11. package/dist/domain/references.js +1 -0
  12. package/dist/entities/index.d.ts +75 -0
  13. package/dist/entities/mail-findings.d.ts +75 -0
  14. package/dist/entities/mail-findings.js +62 -0
  15. package/dist/errors/reasons.js +5 -0
  16. package/dist/http/time-limit.d.ts +11 -0
  17. package/dist/http/time-limit.js +20 -0
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/mail/findings.d.ts +73 -1
  21. package/dist/mail/findings.js +164 -1
  22. package/dist/models/openai.js +4 -3
  23. package/dist/oauth/requests.js +10 -6
  24. package/dist/services/proposals/kinds/mail-findings.js +9 -0
  25. package/dist/services/proposals/kinds/orders.d.ts +4 -0
  26. package/dist/services/proposals/kinds/orders.js +32 -0
  27. package/dist/services/proposals/registry.js +2 -0
  28. package/dist/store/index.d.ts +1 -0
  29. package/dist/store/sqlite/entity-types.d.ts +1 -0
  30. package/dist/store/sqlite/entity-types.js +1 -0
  31. package/dist/store/sqlite/finance.d.ts +40 -2
  32. package/dist/store/sqlite/finance.js +48 -42
  33. package/dist/store/sqlite/knowledge.js +2 -1
  34. package/dist/store/sqlite/mail-findings.js +81 -1
  35. package/dist/store/sqlite/manifest.js +2 -0
  36. package/dist/store/sqlite/migrations.generated.js +20 -0
  37. package/dist/store/sqlite/orders.d.ts +84 -0
  38. package/dist/store/sqlite/orders.js +186 -0
  39. package/dist/store/sqlite/ref-summaries.js +10 -0
  40. package/dist/store/sqlite/schema.d.ts +440 -0
  41. package/dist/store/sqlite/schema.js +38 -0
  42. package/dist/store/sqlite/things.js +1 -0
  43. package/dist/store/store.d.ts +3 -0
  44. package/dist/store/store.js +5 -0
  45. package/package.json +1 -1
@@ -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
- export const financeOperationsOver = (context) => {
60
- const { database, now } = context;
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 = [];
@@ -954,5 +954,25 @@ export const GENERATED = [
954
954
  "statements": [
955
955
  "ALTER TABLE `record_relations` ADD `amount` text;"
956
956
  ]
957
+ },
958
+ {
959
+ "name": "20261105125314_version-50-orders",
960
+ "statements": [
961
+ "CREATE TABLE `orders` (\n\t`id` integer PRIMARY KEY,\n\t`customer_type` text NOT NULL,\n\t`customer_id` integer NOT NULL,\n\t`purpose` text NOT NULL,\n\t`supplier_type` text,\n\t`supplier_id` integer,\n\t`supplier_name` text NOT NULL,\n\t`number` text,\n\t`currency` text,\n\t`total` integer,\n\t`status` text NOT NULL,\n\t`order_date` text,\n\t`fulfilled_date` text,\n\t`invoice_id` integer,\n\t`payment_id` integer,\n\t`project_id` integer,\n\t`review` text NOT NULL,\n\t`author_type` text NOT NULL,\n\t`author_id` integer NOT NULL,\n\t`model` text,\n\t`confirmed_by_type` text,\n\t`confirmed_by_id` integer,\n\t`source` text NOT NULL,\n\t`metadata` text,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\t`deleted_at` integer,\n\tCONSTRAINT `fk_orders_invoice_id_invoices_id_fk` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`),\n\tCONSTRAINT `fk_orders_payment_id_payments_id_fk` FOREIGN KEY (`payment_id`) REFERENCES `payments`(`id`),\n\tCONSTRAINT `fk_orders_project_id_projects_id_fk` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`)\n);",
962
+ "CREATE INDEX `orders_by_customer_type_customer_id` ON `orders` (`customer_type`,`customer_id`);",
963
+ "CREATE INDEX `orders_by_supplier_type_supplier_id` ON `orders` (`supplier_type`,`supplier_id`);",
964
+ "CREATE INDEX `orders_by_invoice_id` ON `orders` (`invoice_id`);",
965
+ "CREATE INDEX `orders_by_payment_id` ON `orders` (`payment_id`);",
966
+ "CREATE INDEX `orders_by_project_id` ON `orders` (`project_id`);",
967
+ "CREATE INDEX `orders_by_author_type_author_id` ON `orders` (`author_type`,`author_id`);",
968
+ "CREATE INDEX `orders_by_confirmed_by_type_confirmed_by_id` ON `orders` (`confirmed_by_type`,`confirmed_by_id`);"
969
+ ]
970
+ },
971
+ {
972
+ "name": "20261105125315_version-50-orders-rules",
973
+ "statements": [
974
+ "-- The store's code checks these first and says why; the triggers keep a stray write from another build or a hand\n-- edit from storing an order with an unknown status or purpose, or an amount without its currency.\nCREATE TRIGGER orders_valid_bi BEFORE INSERT ON orders\n WHEN new.customer_type NOT IN ('person', 'organization')\n OR new.purpose NOT IN ('personal', 'business', 'client', 'other')\n OR new.status NOT IN ('placed', 'shipped', 'delivered', 'cancelled', 'returned')\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected')\n OR (new.total IS NULL) <> (new.currency IS NULL) OR new.total < 0 BEGIN\n SELECT RAISE(ABORT, 'orders: a person or organization customer, a known purpose, status and review, an amount with its currency');\nEND;",
975
+ "CREATE TRIGGER orders_valid_bu BEFORE UPDATE ON orders\n WHEN new.customer_type NOT IN ('person', 'organization')\n OR new.purpose NOT IN ('personal', 'business', 'client', 'other')\n OR new.status NOT IN ('placed', 'shipped', 'delivered', 'cancelled', 'returned')\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected')\n OR (new.total IS NULL) <> (new.currency IS NULL) OR new.total < 0 BEGIN\n SELECT RAISE(ABORT, 'orders: a person or organization customer, a known purpose, status and review, an amount with its currency');\nEND;"
976
+ ]
957
977
  }
958
978
  ];
@@ -0,0 +1,84 @@
1
+ import { type FinanceReview } from "../../domain/finance.js";
2
+ import { type OrderPurpose, type OrderStatus } from "../../domain/orders.js";
3
+ import { type Decider } from "../decisions.js";
4
+ import { type ProposedByFilter } from "./actors.js";
5
+ import { type Party, type Reviewed, type Written } from "./finance.js";
6
+ import { type Author } from "./memories.js";
7
+ import type { StoreContext } from "./open.js";
8
+ export interface Order extends Reviewed {
9
+ id: string;
10
+ ref: string;
11
+ /** One line, as a list or the review inbox prints it. */
12
+ title: string;
13
+ /** Whom it is for: the owner's own person for his own purchase. */
14
+ customer: Party;
15
+ purpose: OrderPurpose;
16
+ supplier: Party | null;
17
+ /** The shop or supplier as its mail names it. */
18
+ supplierName: string;
19
+ number: string | null;
20
+ /** Both `null` when the mail gave no amount. */
21
+ currency: string | null;
22
+ total: string | null;
23
+ status: OrderStatus;
24
+ /** `YYYY-MM-DD`. */
25
+ orderDate: string | null;
26
+ fulfilledDate: string | null;
27
+ invoice: string | null;
28
+ payment: string | null;
29
+ }
30
+ export interface OrderInput extends Written {
31
+ /** `person:` or `organization:`; the owner's own person when left out. */
32
+ customer?: string;
33
+ purpose: OrderPurpose;
34
+ supplier?: string;
35
+ supplierName: string;
36
+ number?: string;
37
+ /** A decimal string in `currency`; give both or neither. */
38
+ total?: string;
39
+ currency?: string;
40
+ /** `placed` when left out. */
41
+ status?: OrderStatus;
42
+ orderDate?: string;
43
+ fulfilledDate?: string;
44
+ invoice?: string;
45
+ payment?: string;
46
+ }
47
+ export interface OrderFilter {
48
+ status?: OrderStatus;
49
+ purpose?: OrderPurpose;
50
+ customer?: string;
51
+ /** The shop's name in any letter case; with `number`, how a later mail finds its order. */
52
+ supplierName?: string;
53
+ number?: string;
54
+ project?: string;
55
+ review?: FinanceReview;
56
+ proposedBy?: ProposedByFilter;
57
+ /** 1–500, 100 by default; newest written first. */
58
+ limit?: number;
59
+ }
60
+ /**
61
+ * What the owner, his business or a client ordered, from whom, and how far delivery got. The owner's entry is
62
+ * confirmed at once; an agent's waits as proposed for him, and only he decides it.
63
+ */
64
+ export interface OrdersStore {
65
+ add(input: OrderInput): Promise<Order>;
66
+ get(reference: string): Promise<Order>;
67
+ list(filter?: OrderFilter): Promise<Order[]>;
68
+ /** The owner moves an order on, such as a later mail saying it shipped; `date` is the delivery day. */
69
+ setStatus(reference: string, input: {
70
+ status: OrderStatus;
71
+ date?: string;
72
+ by: Author;
73
+ }): Promise<Order>;
74
+ confirm(reference: string, by: Decider, reason?: string): Promise<Order>;
75
+ reject(reference: string, by: Decider, reason?: string): Promise<Order>;
76
+ }
77
+ type OrderOperations = {
78
+ [K in keyof OrdersStore]: OrdersStore[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => R : never;
79
+ };
80
+ export declare const orderOperationsOver: (context: StoreContext) => OrderOperations;
81
+ /** The one stored order of a shop with a number, as a later mail names it; refuses none and more than one. */
82
+ export declare const orderOfShop: (context: StoreContext, supplierName: string, number: string) => string;
83
+ export declare const ordersStoreOver: (context: StoreContext) => OrdersStore;
84
+ export {};
@@ -0,0 +1,186 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { FINANCE_REVIEWS } from "../../domain/finance.js";
3
+ import { currencyOf, formatDecimal, minorUnits, scaleOf } from "../../domain/money.js";
4
+ import { ORDER_PURPOSES, ORDER_STATUSES, orderTitle } from "../../domain/orders.js";
5
+ import { assertMayDecide, deciderAuthor } from "../decisions.js";
6
+ import { fold } from "../normalize.js";
7
+ import { ownerPerson, proposedBySql, proposerOf } from "./actors.js";
8
+ import { isoDate, oneOf, refuse, reviewedRowsOver, text } from "./finance.js";
9
+ import { authorActor, projectId } from "./memories.js";
10
+ import { requiredThing } from "./notes.js";
11
+ import { write } from "./transaction.js";
12
+ const ORDERS = "SELECT o.*, b.name AS author_name, b.kind AS author_kind FROM orders o " +
13
+ "LEFT JOIN bots b ON o.author_type = 'bot' AND b.id = o.author_id";
14
+ export const orderOperationsOver = (context) => {
15
+ const { database, now } = context;
16
+ const { partyOf, partyThing, reviewedOf, written, keepEvidence, ownerOnly, keepDecision } = reviewedRowsOver(context);
17
+ const orderOf = (row) => {
18
+ const currency = row.currency == null ? null : String(row.currency);
19
+ const total = row.total == null || currency === null ? null : Number(row.total);
20
+ const fields = {
21
+ id: Number(row.id),
22
+ number: row.number == null ? null : String(row.number),
23
+ supplierName: String(row.supplier_name),
24
+ total,
25
+ currency,
26
+ status: row.status,
27
+ review: row.review,
28
+ };
29
+ return {
30
+ id: String(row.id),
31
+ ref: `order:${row.id}`,
32
+ title: orderTitle(fields),
33
+ customer: partyOf(row.customer_type, row.customer_id),
34
+ purpose: row.purpose,
35
+ supplier: partyOf(row.supplier_type, row.supplier_id),
36
+ supplierName: fields.supplierName,
37
+ number: fields.number,
38
+ currency,
39
+ total: total === null || currency === null ? null : formatDecimal(total, scaleOf(currency)),
40
+ status: fields.status,
41
+ orderDate: row.order_date == null ? null : String(row.order_date),
42
+ fulfilledDate: row.fulfilled_date == null ? null : String(row.fulfilled_date),
43
+ invoice: row.invoice_id == null ? null : `invoice:${row.invoice_id}`,
44
+ payment: row.payment_id == null ? null : `payment:${row.payment_id}`,
45
+ ...reviewedOf(row, "order"),
46
+ };
47
+ };
48
+ const rowOf = (reference) => {
49
+ const thing = requiredThing(database, reference.includes(":") ? reference : `order:${reference}`, "the order");
50
+ if (thing.type !== "order")
51
+ return refuse(`${reference} is not an order`, "order.ref");
52
+ return database.prepare(`${ORDERS} WHERE o.id = ?`).get(thing.id);
53
+ };
54
+ const linked = (reference, type) => {
55
+ if (reference === undefined)
56
+ return null;
57
+ const thing = requiredThing(database, reference, `the ${type}`);
58
+ if (thing.type !== type)
59
+ return refuse(`${reference} is not an ${type}: reference`, "order.ref");
60
+ return thing.id;
61
+ };
62
+ const statusOf = (value) => oneOf(value, ORDER_STATUSES, "an order's status");
63
+ const decide = (reference, by, reason, confirm) => {
64
+ const row = rowOf(reference);
65
+ const ref = `order:${row.id}`;
66
+ assertMayDecide({ id: ref, proposedBy: proposerOf(database, row.author_type, row.author_id), ownerOnly: true }, by, context.decisions);
67
+ if (row.review !== "proposed")
68
+ refuse(`${ref} is already ${String(row.review)}`, "proposal.wrong-state");
69
+ write(database, () => {
70
+ if (confirm) {
71
+ const { actor } = authorActor(context, deciderAuthor(by));
72
+ database
73
+ .prepare("UPDATE orders SET review = 'confirmed', confirmed_by_type = ?, confirmed_by_id = ?, updated_at = ? WHERE id = ?")
74
+ .run(actor.type, actor.id, now(), Number(row.id));
75
+ }
76
+ else
77
+ database
78
+ .prepare("UPDATE orders SET review = 'rejected', updated_at = ? WHERE id = ?")
79
+ .run(now(), Number(row.id));
80
+ keepDecision("orders", Number(row.id), by, reason);
81
+ });
82
+ return orderOf(rowOf(ref));
83
+ };
84
+ return {
85
+ add: (input) => {
86
+ const purpose = oneOf(input.purpose, ORDER_PURPOSES, "an order's purpose");
87
+ const status = input.status === undefined ? "placed" : statusOf(input.status);
88
+ const supplierName = text(input.supplierName, "an order's supplier name", 200) ??
89
+ refuse("an order names its shop or supplier", "order.field");
90
+ const number = text(input.number, "an order's number", 80);
91
+ if ((input.total === undefined) !== (input.currency === undefined))
92
+ refuse("an order's amount and currency come together", "order.field");
93
+ const currency = input.currency === undefined ? null : currencyOf(input.currency);
94
+ const total = currency === null ? null : minorUnits(input.total, currency, "an order's amount");
95
+ if (total !== null && total < 0)
96
+ refuse("an order's amount is 0 or more", "money.amount");
97
+ const orderDate = isoDate(input.orderDate, "an order's date");
98
+ const fulfilledDate = isoDate(input.fulfilledDate, "an order's delivery date");
99
+ const id = write(database, () => {
100
+ if (number !== null && ordersOfShop(database, supplierName, number).length)
101
+ refuse(`order ${number} from ${supplierName} is already stored; change its status instead`, "order.duplicate");
102
+ const customer = partyThing(input.customer, "the customer") ?? ownerPerson(database);
103
+ const supplier = partyThing(input.supplier, "the supplier");
104
+ const invoice = linked(input.invoice, "invoice");
105
+ const payment = linked(input.payment, "payment");
106
+ const by = written(input);
107
+ const at = now();
108
+ const made = Number(database
109
+ .prepare("INSERT INTO orders (customer_type, customer_id, purpose, supplier_type, supplier_id, supplier_name, number, " +
110
+ "currency, total, status, order_date, fulfilled_date, invoice_id, payment_id, project_id, review, " +
111
+ "author_type, author_id, model, confirmed_by_type, confirmed_by_id, source, metadata, created_at, updated_at) " +
112
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
113
+ .get(customer.type, customer.id, purpose, supplier?.type ?? null, supplier?.id ?? null, supplierName, number, currency, total, status, orderDate, fulfilledDate, invoice, payment, by.project, by.review, by.actor.type, by.actor.id, by.model, by.confirmedType, by.confirmedId, by.source, by.metadata, at, at)?.id);
114
+ keepEvidence("order", made, by);
115
+ return made;
116
+ });
117
+ return orderOf(rowOf(`order:${id}`));
118
+ },
119
+ get: (reference) => orderOf(rowOf(reference)),
120
+ list: (filter = {}) => {
121
+ const project = filter.project === undefined ? null : projectId(database, filter.project);
122
+ const customer = partyThing(filter.customer, "the customer");
123
+ const by = proposedBySql("o.author_type", "o.author_id", filter.proposedBy);
124
+ const status = filter.status === undefined ? null : statusOf(filter.status);
125
+ const purpose = filter.purpose === undefined ? null : oneOf(filter.purpose, ORDER_PURPOSES, "a purpose");
126
+ const review = filter.review === undefined ? null : oneOf(filter.review, FINANCE_REVIEWS, "a review");
127
+ const number = filter.number === undefined ? null : filter.number.trim();
128
+ const shop = filter.supplierName === undefined ? null : fold(filter.supplierName);
129
+ const limit = Math.min(Math.max(filter.limit ?? 100, 1), 500);
130
+ const rows = database
131
+ .prepare(`${ORDERS} WHERE o.deleted_at IS NULL AND (? IS NULL OR o.status = ?) AND (? IS NULL OR o.purpose = ?) ` +
132
+ "AND (? IS NULL OR o.review = ?) AND (? IS NULL OR o.project_id = ?) AND (? IS NULL OR o.number = ?) " +
133
+ "AND (? IS NULL OR (o.customer_type = ? AND o.customer_id = ?)) " +
134
+ `AND ${by.sql} ORDER BY o.created_at DESC, o.id DESC`)
135
+ .all(status, status, purpose, purpose, review, review, project, project, number, number, customer?.type ?? null, customer?.type ?? null, customer?.id ?? null, ...by.params);
136
+ return rows
137
+ .filter((row) => shop === null || fold(String(row.supplier_name)) === shop)
138
+ .slice(0, limit)
139
+ .map(orderOf);
140
+ },
141
+ setStatus: (reference, { status, date, by }) => {
142
+ ownerOnly(by, "moves an order on");
143
+ const next = statusOf(status);
144
+ const day = isoDate(date, "an order's delivery date");
145
+ const row = rowOf(reference);
146
+ if (row.review === "rejected" || row.deleted_at != null)
147
+ refuse(`order:${row.id} is ${row.deleted_at != null ? "deleted" : "rejected"}`, "order.status");
148
+ write(database, () => {
149
+ database
150
+ .prepare("UPDATE orders SET status = ?, fulfilled_date = coalesce(?, fulfilled_date), updated_at = ? WHERE id = ?")
151
+ .run(next, next === "delivered" ? day : null, now(), Number(row.id));
152
+ });
153
+ return orderOf(rowOf(`order:${row.id}`));
154
+ },
155
+ confirm: (reference, by, reason) => decide(reference, by, reason, true),
156
+ reject: (reference, by, reason) => decide(reference, by, reason, false),
157
+ };
158
+ };
159
+ /** Orders of one shop with one number that still count: neither rejected nor deleted, the name in any letter case. */
160
+ const ordersOfShop = (database, supplierName, number) => {
161
+ const name = fold(supplierName);
162
+ return database
163
+ .prepare("SELECT id, supplier_name FROM orders WHERE number = ? AND deleted_at IS NULL AND review <> 'rejected'")
164
+ .all(number.trim())
165
+ .filter((row) => fold(String(row.supplier_name)) === name);
166
+ };
167
+ /** The one stored order of a shop with a number, as a later mail names it; refuses none and more than one. */
168
+ export const orderOfShop = (context, supplierName, number) => {
169
+ const rows = ordersOfShop(context.database, supplierName, number);
170
+ if (rows.length === 0)
171
+ throw new CliError("not_found", `no stored order ${number} from ${supplierName}`, { reason: "order.not-found" });
172
+ if (rows.length > 1)
173
+ return refuse(`more than one order ${number} from ${supplierName}`, "order.ref");
174
+ return `order:${rows[0]?.id}`;
175
+ };
176
+ export const ordersStoreOver = (context) => {
177
+ const operations = orderOperationsOver(context);
178
+ return {
179
+ add: async (input) => operations.add(input),
180
+ get: async (reference) => operations.get(reference),
181
+ list: async (filter) => operations.list(filter),
182
+ setStatus: async (reference, input) => operations.setStatus(reference, input),
183
+ confirm: async (reference, by, reason) => operations.confirm(reference, by, reason),
184
+ reject: async (reference, by, reason) => operations.reject(reference, by, reason),
185
+ };
186
+ };
@@ -1,4 +1,5 @@
1
1
  import { invoiceLine, paymentLine } from "../../domain/finance.js";
2
+ import { orderLine } from "../../domain/orders.js";
2
3
  import { filesAccountSql, providerLabel, providerNamed } from "../../domain/providers.js";
3
4
  import { oneLine } from "../../domain/ref-summaries.js";
4
5
  import { formatReference, parseReference } from "../../domain/references.js";
@@ -199,6 +200,13 @@ const READERS = {
199
200
  at: "x.created_at",
200
201
  deleted: "x.deleted_at",
201
202
  })),
203
+ order: byId("order", "orders", () => ({
204
+ title: "json_object('id', x.id, 'number', x.number, 'supplierName', x.supplier_name, 'total', x.total, " +
205
+ "'currency', x.currency, 'status', x.status, 'review', x.review)",
206
+ holder: "(SELECT name FROM projects WHERE id = x.project_id)",
207
+ at: "x.created_at",
208
+ deleted: "x.deleted_at",
209
+ })),
202
210
  };
203
211
  /** Mail saved before the mail tables existed is still messages, so a miss there is read again as a message. */
204
212
  const FALLBACK = { email: "message", email_thread: "chat" };
@@ -304,6 +312,8 @@ const titleOf = (kind, reference, found) => {
304
312
  return fieldsOf(found.title, invoiceLine);
305
313
  case "payment":
306
314
  return fieldsOf(found.title, paymentLine);
315
+ case "order":
316
+ return fieldsOf(found.title, orderLine);
307
317
  case "folder":
308
318
  return reference.type === "folder" && reference.path !== null && found.title !== null
309
319
  ? `${found.title}/${reference.path}`