@wirecat/cli-core 0.555.0 → 0.557.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 +59 -0
- package/dist/cli/config-command.js +64 -2
- package/dist/cli/config-issues.d.ts +2 -0
- package/dist/cli/config-issues.js +11 -3
- package/dist/cli/config-repair.d.ts +26 -0
- package/dist/cli/config-repair.js +252 -0
- package/dist/cli/messenger/tasks-analysis-command.d.ts +8 -0
- package/dist/cli/messenger/tasks-analysis-command.js +184 -0
- package/dist/cli/messenger/tasks-command.js +11 -2
- package/dist/cli/steps.js +10 -0
- package/dist/domain/finance.d.ts +40 -0
- package/dist/domain/finance.js +39 -0
- package/dist/domain/money.d.ts +29 -0
- package/dist/domain/money.js +98 -0
- package/dist/domain/ref-summaries.js +2 -0
- package/dist/domain/references.d.ts +1 -1
- package/dist/domain/references.js +2 -0
- package/dist/entities/index.d.ts +64 -2
- package/dist/entities/index.js +5 -2
- package/dist/entities/task-results.d.ts +72 -0
- package/dist/entities/task-results.js +40 -1
- package/dist/errors/reasons.d.ts +1 -1
- package/dist/errors/reasons.js +24 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mail/cli/index.d.ts +1 -0
- package/dist/mail/cli/index.js +3 -0
- package/dist/mail/cli/marks.d.ts +26 -0
- package/dist/mail/cli/marks.js +51 -0
- package/dist/mail/cli/review.js +70 -1
- package/dist/mail/index.d.ts +1 -0
- package/dist/mail/index.js +1 -0
- package/dist/mail/marks.d.ts +56 -0
- package/dist/mail/marks.js +178 -0
- package/dist/meetings/mcp/index.d.ts +15 -15
- package/dist/sends/permissions.js +1 -0
- package/dist/services/proposals/kinds/finance.d.ts +4 -0
- package/dist/services/proposals/kinds/finance.js +40 -0
- package/dist/services/proposals/registry.js +3 -0
- package/dist/services/task-analysis.d.ts +58 -0
- package/dist/services/task-analysis.js +125 -0
- package/dist/store/exports.d.ts +2 -2
- package/dist/store/index.d.ts +2 -1
- package/dist/store/sqlite/email-activity.d.ts +31 -0
- package/dist/store/sqlite/email-activity.js +38 -0
- package/dist/store/sqlite/emails.d.ts +2 -0
- package/dist/store/sqlite/emails.js +1 -0
- package/dist/store/sqlite/entity-types.d.ts +2 -0
- package/dist/store/sqlite/entity-types.js +2 -0
- package/dist/store/sqlite/finance.d.ts +199 -0
- package/dist/store/sqlite/finance.js +514 -0
- package/dist/store/sqlite/knowledge.js +3 -1
- package/dist/store/sqlite/manifest.js +2 -0
- package/dist/store/sqlite/migrations.generated.js +35 -0
- package/dist/store/sqlite/ref-summaries.js +33 -0
- package/dist/store/sqlite/schema.d.ts +1043 -0
- package/dist/store/sqlite/schema.js +98 -0
- package/dist/store/sqlite/task-analysis-marks.d.ts +7 -0
- package/dist/store/sqlite/task-analysis-marks.js +27 -0
- package/dist/store/sqlite/things.js +2 -0
- package/dist/store/store.d.ts +3 -0
- package/dist/store/store.js +5 -0
- package/dist/store/sync.d.ts +5 -1
- package/dist/store/sync.js +4 -0
- package/dist/store/types.d.ts +25 -0
- package/package.json +1 -1
- package/parity.json +29 -0
- package/skills/follow-ups/SKILL.md +22 -2
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { FINANCE_REVIEWS, INVOICE_DIRECTIONS, INVOICE_STATUSES, invoiceTitle, MONEY_DIRECTIONS, paymentEntryTitle, settledStatus, } from "../../domain/finance.js";
|
|
3
|
+
import { currencyOf, formatDecimal, lineUnits, minorUnits, moneyLabel, moneyOf, quantityOf, scaleOf, } from "../../domain/money.js";
|
|
4
|
+
import { assertMayDecide, botKey, deciderAuthor } from "../decisions.js";
|
|
5
|
+
import { proposedBySql, proposerOf } from "./actors.js";
|
|
6
|
+
import { authorActor, authorOf, projectId, projectOf, subjectOf } from "./memories.js";
|
|
7
|
+
import { requiredThing } from "./notes.js";
|
|
8
|
+
import { write } from "./transaction.js";
|
|
9
|
+
const refuse = (message, reason, code = "validation_error") => {
|
|
10
|
+
throw new CliError(code, message, { reason });
|
|
11
|
+
};
|
|
12
|
+
const oneOf = (value, allowed, what) => {
|
|
13
|
+
if (typeof value !== "string" || !allowed.includes(value))
|
|
14
|
+
return refuse(`${what} is one of ${allowed.join(", ")}`, "finance.field");
|
|
15
|
+
return value;
|
|
16
|
+
};
|
|
17
|
+
const text = (value, what, limit) => {
|
|
18
|
+
if (value === undefined || value === null)
|
|
19
|
+
return null;
|
|
20
|
+
if (typeof value !== "string" || !value.trim() || value.length > limit || value.includes("\0"))
|
|
21
|
+
return refuse(`${what} takes 1–${limit} characters`, "finance.field");
|
|
22
|
+
return value.trim();
|
|
23
|
+
};
|
|
24
|
+
const isoDate = (value, what) => {
|
|
25
|
+
if (value === undefined || value === null)
|
|
26
|
+
return null;
|
|
27
|
+
const date = typeof value === "string" ? value.trim() : "";
|
|
28
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) !== date)
|
|
29
|
+
return refuse(`${what} is a date such as 2026-11-15`, "finance.field");
|
|
30
|
+
return date;
|
|
31
|
+
};
|
|
32
|
+
const iso = (value) => new Date(Number(value)).toISOString();
|
|
33
|
+
const MAX_LINES = 500;
|
|
34
|
+
const reasonOf = (metadata) => {
|
|
35
|
+
if (metadata == null)
|
|
36
|
+
return null;
|
|
37
|
+
try {
|
|
38
|
+
const { reason } = JSON.parse(String(metadata));
|
|
39
|
+
return typeof reason === "string" ? reason : null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/** Money settled by confirmed payments only: a proposed payment promises nothing yet. */
|
|
46
|
+
const PAID = "(SELECT coalesce(sum(a.amount), 0) FROM payment_allocations a JOIN payments p ON p.id = a.payment_id " +
|
|
47
|
+
"WHERE a.invoice_id = i.id AND p.review = 'confirmed' AND p.deleted_at IS NULL)";
|
|
48
|
+
const INVOICES = `SELECT i.*, ${PAID} AS paid, b.name AS author_name, b.kind AS author_kind FROM invoices i ` +
|
|
49
|
+
"LEFT JOIN bots b ON i.author_type = 'bot' AND b.id = i.author_id";
|
|
50
|
+
const PAYMENTS = "SELECT p.*, (SELECT coalesce(sum(amount), 0) FROM payment_allocations WHERE payment_id = p.id) AS allocated, " +
|
|
51
|
+
"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";
|
|
52
|
+
export const financeOperationsOver = (context) => {
|
|
53
|
+
const { database, now } = context;
|
|
54
|
+
const partyOf = (type, id) => {
|
|
55
|
+
if (type == null)
|
|
56
|
+
return null;
|
|
57
|
+
const { subject, subjectName, subjectIsOwner } = subjectOf(database, { subject_type: type, subject_id: id });
|
|
58
|
+
return subject === null ? null : { ref: subject, name: subjectName, owner: subjectIsOwner };
|
|
59
|
+
};
|
|
60
|
+
const partyThing = (reference, what) => {
|
|
61
|
+
if (reference === undefined)
|
|
62
|
+
return undefined;
|
|
63
|
+
const thing = requiredThing(database, reference, what);
|
|
64
|
+
if (thing.type !== "person" && thing.type !== "organization")
|
|
65
|
+
return refuse(`${what} is a person: or organization: reference, not ${reference}`, "finance.party");
|
|
66
|
+
return thing;
|
|
67
|
+
};
|
|
68
|
+
const reviewedOf = (row) => ({
|
|
69
|
+
review: row.review,
|
|
70
|
+
author: authorOf(row),
|
|
71
|
+
model: row.model == null ? null : String(row.model),
|
|
72
|
+
confirmedBy: row.confirmed_by_type == null
|
|
73
|
+
? null
|
|
74
|
+
: { type: row.confirmed_by_type, id: String(row.confirmed_by_id) },
|
|
75
|
+
source: String(row.source),
|
|
76
|
+
reason: reasonOf(row.metadata),
|
|
77
|
+
...projectOf(database, row),
|
|
78
|
+
createdAt: iso(row.created_at),
|
|
79
|
+
updatedAt: iso(row.updated_at),
|
|
80
|
+
});
|
|
81
|
+
const invoiceOf = (row) => {
|
|
82
|
+
const currency = String(row.currency);
|
|
83
|
+
const scale = scaleOf(currency);
|
|
84
|
+
const [total, paid] = [Number(row.total), Number(row.paid)];
|
|
85
|
+
const counterparty = partyOf(row.counterparty_type, row.counterparty_id);
|
|
86
|
+
const fields = {
|
|
87
|
+
id: Number(row.id),
|
|
88
|
+
number: row.number == null ? null : String(row.number),
|
|
89
|
+
direction: row.direction,
|
|
90
|
+
total,
|
|
91
|
+
currency,
|
|
92
|
+
counterpartyName: counterparty?.name ?? null,
|
|
93
|
+
dueDate: row.due_date == null ? null : String(row.due_date),
|
|
94
|
+
status: row.status,
|
|
95
|
+
review: row.review,
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
id: String(row.id),
|
|
99
|
+
ref: `invoice:${row.id}`,
|
|
100
|
+
title: invoiceTitle(fields),
|
|
101
|
+
direction: fields.direction,
|
|
102
|
+
number: fields.number,
|
|
103
|
+
issuer: partyOf(row.issuer_type, row.issuer_id),
|
|
104
|
+
counterparty,
|
|
105
|
+
currency,
|
|
106
|
+
total: formatDecimal(total, scale),
|
|
107
|
+
paid: formatDecimal(paid, scale),
|
|
108
|
+
open: formatDecimal(Math.max(total - paid, 0), scale),
|
|
109
|
+
issueDate: row.issue_date == null ? null : String(row.issue_date),
|
|
110
|
+
dueDate: fields.dueDate,
|
|
111
|
+
status: fields.status,
|
|
112
|
+
...reviewedOf(row),
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
const paymentOf = (row) => {
|
|
116
|
+
const currency = String(row.currency);
|
|
117
|
+
const scale = scaleOf(currency);
|
|
118
|
+
const [amount, allocated] = [Number(row.amount), Number(row.allocated)];
|
|
119
|
+
const direction = row.direction;
|
|
120
|
+
const payer = partyOf(row.payer_type, row.payer_id);
|
|
121
|
+
const payee = partyOf(row.payee_type, row.payee_id);
|
|
122
|
+
const date = String(row.payment_date);
|
|
123
|
+
return {
|
|
124
|
+
id: String(row.id),
|
|
125
|
+
ref: `payment:${row.id}`,
|
|
126
|
+
title: paymentEntryTitle({
|
|
127
|
+
direction,
|
|
128
|
+
amount,
|
|
129
|
+
currency,
|
|
130
|
+
partyName: (direction === "in" ? payer : payee)?.name ?? null,
|
|
131
|
+
date,
|
|
132
|
+
review: row.review,
|
|
133
|
+
}),
|
|
134
|
+
direction,
|
|
135
|
+
payer,
|
|
136
|
+
payee,
|
|
137
|
+
amount: formatDecimal(amount, scale),
|
|
138
|
+
currency,
|
|
139
|
+
date,
|
|
140
|
+
reference: row.reference == null ? null : String(row.reference),
|
|
141
|
+
allocated: formatDecimal(allocated, scale),
|
|
142
|
+
unallocated: formatDecimal(amount - allocated, scale),
|
|
143
|
+
...reviewedOf(row),
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
const allocationsWhere = (column, id) => database
|
|
147
|
+
.prepare("SELECT a.*, p.currency FROM payment_allocations a JOIN payments p ON p.id = a.payment_id " +
|
|
148
|
+
`WHERE a.${column} = ? ORDER BY a.id`)
|
|
149
|
+
.all(id)
|
|
150
|
+
.map((row) => ({
|
|
151
|
+
payment: `payment:${row.payment_id}`,
|
|
152
|
+
invoice: `invoice:${row.invoice_id}`,
|
|
153
|
+
amount: formatDecimal(Number(row.amount), scaleOf(String(row.currency))),
|
|
154
|
+
currency: String(row.currency),
|
|
155
|
+
createdAt: iso(row.created_at),
|
|
156
|
+
}));
|
|
157
|
+
const rowOf = (reference, type) => {
|
|
158
|
+
const thing = requiredThing(database, reference.includes(":") ? reference : `${type}:${reference}`, `the ${type}`);
|
|
159
|
+
if (thing.type !== type)
|
|
160
|
+
return refuse(`${reference} is not an ${type}`, "finance.ref");
|
|
161
|
+
return database.prepare(`${type === "invoice" ? INVOICES : PAYMENTS} WHERE ${type[0]}.id = ?`).get(thing.id);
|
|
162
|
+
};
|
|
163
|
+
const invoiceDetail = (reference) => {
|
|
164
|
+
const row = rowOf(reference, "invoice");
|
|
165
|
+
const scale = scaleOf(String(row.currency));
|
|
166
|
+
const lines = database
|
|
167
|
+
.prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY position")
|
|
168
|
+
.all(Number(row.id))
|
|
169
|
+
.map((line) => ({
|
|
170
|
+
position: Number(line.position),
|
|
171
|
+
description: String(line.description),
|
|
172
|
+
quantity: String(line.quantity),
|
|
173
|
+
unitPrice: line.unit_price == null ? null : formatDecimal(Number(line.unit_price), scale),
|
|
174
|
+
amount: formatDecimal(Number(line.amount), scale),
|
|
175
|
+
}));
|
|
176
|
+
return { ...invoiceOf(row), lines, allocations: allocationsWhere("invoice_id", Number(row.id)) };
|
|
177
|
+
};
|
|
178
|
+
const paymentDetail = (reference) => {
|
|
179
|
+
const row = rowOf(reference, "payment");
|
|
180
|
+
return { ...paymentOf(row), allocations: allocationsWhere("payment_id", Number(row.id)) };
|
|
181
|
+
};
|
|
182
|
+
const settle = (invoiceId) => {
|
|
183
|
+
const row = database
|
|
184
|
+
.prepare(`SELECT i.status, i.total, ${PAID} AS paid FROM invoices i WHERE i.id = ?`)
|
|
185
|
+
.get(invoiceId);
|
|
186
|
+
if (!row)
|
|
187
|
+
return;
|
|
188
|
+
const status = settledStatus(row.status, Number(row.total), Number(row.paid));
|
|
189
|
+
database
|
|
190
|
+
.prepare("UPDATE invoices SET status = ?, updated_at = ? WHERE id = ? AND status <> ?")
|
|
191
|
+
.run(status, now(), invoiceId, status);
|
|
192
|
+
};
|
|
193
|
+
const allocateRow = (payment, reference, units, actor) => {
|
|
194
|
+
const invoice = rowOf(reference, "invoice");
|
|
195
|
+
const [paymentRef, invoiceRef] = [`payment:${payment.id}`, `invoice:${invoice.id}`];
|
|
196
|
+
if (invoice.deleted_at != null || invoice.review === "rejected")
|
|
197
|
+
refuse(`${invoiceRef} is ${invoice.review === "rejected" ? "rejected" : "deleted"}`, "finance.allocate");
|
|
198
|
+
if (invoice.status === "draft" || invoice.status === "void")
|
|
199
|
+
refuse(`${invoiceRef} is ${invoice.status}; only an issued invoice is paid`, "finance.allocate");
|
|
200
|
+
const currency = String(payment.currency);
|
|
201
|
+
if (invoice.currency !== currency)
|
|
202
|
+
refuse(`${paymentRef} is in ${currency} and ${invoiceRef} in ${String(invoice.currency)}; the store converts nothing`, "finance.currency");
|
|
203
|
+
const fromPayment = Number(database
|
|
204
|
+
.prepare("SELECT coalesce(sum(amount), 0) AS n FROM payment_allocations WHERE payment_id = ?")
|
|
205
|
+
.get(Number(payment.id))?.n);
|
|
206
|
+
if (fromPayment + units > Number(payment.amount))
|
|
207
|
+
refuse(`${paymentRef} is ${moneyLabel(Number(payment.amount), currency)} and ` +
|
|
208
|
+
`${moneyLabel(fromPayment, currency)} of it is already allocated; ${moneyLabel(units, currency)} more is too much`, "finance.over-payment");
|
|
209
|
+
const toInvoice = Number(database
|
|
210
|
+
.prepare("SELECT coalesce(sum(a.amount), 0) AS n FROM payment_allocations a JOIN payments p ON p.id = a.payment_id " +
|
|
211
|
+
"WHERE a.invoice_id = ? AND ((p.review = 'confirmed' AND p.deleted_at IS NULL) OR a.payment_id = ?)")
|
|
212
|
+
.get(Number(invoice.id), Number(payment.id))?.n);
|
|
213
|
+
if (toInvoice + units > Number(invoice.total))
|
|
214
|
+
refuse(`${invoiceRef} totals ${moneyLabel(Number(invoice.total), currency)} and ` +
|
|
215
|
+
`${moneyLabel(toInvoice, currency)} is already allocated to it; ${moneyLabel(units, currency)} more is too much`, "finance.over-invoice");
|
|
216
|
+
const at = now();
|
|
217
|
+
database
|
|
218
|
+
.prepare("INSERT INTO payment_allocations (payment_id, invoice_id, amount, author_type, author_id, created_at, updated_at) " +
|
|
219
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (payment_id, invoice_id) " +
|
|
220
|
+
"DO UPDATE SET amount = amount + excluded.amount, updated_at = excluded.updated_at")
|
|
221
|
+
.run(Number(payment.id), Number(invoice.id), units, actor.type, actor.id, at, at);
|
|
222
|
+
return Number(invoice.id);
|
|
223
|
+
};
|
|
224
|
+
const written = (input) => {
|
|
225
|
+
const owner = input.by === "owner";
|
|
226
|
+
const { actor, model } = authorActor(context, input.by);
|
|
227
|
+
const source = owner ? "owner" : (text(input.source, "a source", 80) ?? "agent");
|
|
228
|
+
const reason = owner ? null : text(input.reason, "a reason", 2000);
|
|
229
|
+
return {
|
|
230
|
+
owner,
|
|
231
|
+
actor,
|
|
232
|
+
model,
|
|
233
|
+
source,
|
|
234
|
+
review: owner ? "confirmed" : "proposed",
|
|
235
|
+
confirmedType: owner ? actor.type : null,
|
|
236
|
+
confirmedId: owner ? actor.id : null,
|
|
237
|
+
metadata: reason === null ? null : JSON.stringify({ reason }),
|
|
238
|
+
project: input.project === undefined ? null : projectId(database, input.project),
|
|
239
|
+
};
|
|
240
|
+
};
|
|
241
|
+
const ownerOnly = (by, what) => {
|
|
242
|
+
if (by !== "owner")
|
|
243
|
+
refuse(`only the owner ${what}`, "finance.owner-only", "permission_error");
|
|
244
|
+
};
|
|
245
|
+
const keepDecision = (table, id, by, reason) => {
|
|
246
|
+
const why = [by === "owner" ? undefined : by.reason.trim(), reason?.trim() || undefined].filter(Boolean).join(": ");
|
|
247
|
+
if (by === "owner" && !why)
|
|
248
|
+
return;
|
|
249
|
+
const decision = by === "owner" ? { by: "owner", reason: why } : { by: "agent", bot: by.bot, reason: why };
|
|
250
|
+
database
|
|
251
|
+
.prepare(`UPDATE ${table} SET metadata = json_set(coalesce(metadata, '{}'), '$.decision', json(?)) WHERE id = ?`)
|
|
252
|
+
.run(JSON.stringify(decision), id);
|
|
253
|
+
};
|
|
254
|
+
/** A proposed payment's allocations count once confirmed; by then other confirmed payments may cover the invoice. */
|
|
255
|
+
const coveredElsewhere = (paymentId) => {
|
|
256
|
+
const over = database
|
|
257
|
+
.prepare("SELECT a.invoice_id, a.amount, i.total, i.currency, (SELECT coalesce(sum(o.amount), 0) FROM payment_allocations o " +
|
|
258
|
+
"JOIN payments p ON p.id = o.payment_id WHERE o.invoice_id = a.invoice_id AND o.payment_id <> a.payment_id " +
|
|
259
|
+
"AND p.review = 'confirmed' AND p.deleted_at IS NULL) AS others FROM payment_allocations a " +
|
|
260
|
+
"JOIN invoices i ON i.id = a.invoice_id WHERE a.payment_id = ? AND a.amount + others > i.total LIMIT 1")
|
|
261
|
+
.get(paymentId);
|
|
262
|
+
if (!over)
|
|
263
|
+
return;
|
|
264
|
+
const currency = String(over.currency);
|
|
265
|
+
refuse(`invoice:${over.invoice_id} is already covered by other payments: ${moneyLabel(Number(over.others), currency)} ` +
|
|
266
|
+
`of ${moneyLabel(Number(over.total), currency)}; payment:${paymentId} would add ` +
|
|
267
|
+
`${moneyLabel(Number(over.amount), currency)}, so it stays proposed`, "finance.over-invoice");
|
|
268
|
+
};
|
|
269
|
+
const decide = (reference, by, reason, confirm) => {
|
|
270
|
+
const thing = requiredThing(database, reference, "the proposal");
|
|
271
|
+
if (thing.type !== "invoice" && thing.type !== "payment")
|
|
272
|
+
return refuse(`${reference} is not an invoice: or payment:`, "finance.ref");
|
|
273
|
+
const type = thing.type;
|
|
274
|
+
const table = type === "invoice" ? "invoices" : "payments";
|
|
275
|
+
const row = rowOf(reference, type);
|
|
276
|
+
const ref = `${type}:${row.id}`;
|
|
277
|
+
assertMayDecide({ id: ref, proposedBy: proposerOf(database, row.author_type, row.author_id), ownerOnly: true }, by, context.decisions);
|
|
278
|
+
if (row.review !== "proposed")
|
|
279
|
+
refuse(`${ref} is already ${String(row.review)}`, "proposal.wrong-state");
|
|
280
|
+
const id = Number(row.id);
|
|
281
|
+
write(database, () => {
|
|
282
|
+
const at = now();
|
|
283
|
+
if (confirm && type === "payment")
|
|
284
|
+
coveredElsewhere(id);
|
|
285
|
+
if (confirm) {
|
|
286
|
+
const { actor } = authorActor(context, deciderAuthor(by));
|
|
287
|
+
database
|
|
288
|
+
.prepare(`UPDATE ${table} SET review = 'confirmed', confirmed_by_type = ?, confirmed_by_id = ?, updated_at = ? WHERE id = ?`)
|
|
289
|
+
.run(actor.type, actor.id, at, id);
|
|
290
|
+
}
|
|
291
|
+
else
|
|
292
|
+
database.prepare(`UPDATE ${table} SET review = 'rejected', updated_at = ? WHERE id = ?`).run(at, id);
|
|
293
|
+
keepDecision(table, id, by, reason);
|
|
294
|
+
const touched = type === "invoice"
|
|
295
|
+
? [id]
|
|
296
|
+
: database
|
|
297
|
+
.prepare("SELECT invoice_id FROM payment_allocations WHERE payment_id = ?")
|
|
298
|
+
.all(id)
|
|
299
|
+
.map((one) => Number(one.invoice_id));
|
|
300
|
+
if (!confirm)
|
|
301
|
+
database
|
|
302
|
+
.prepare(`DELETE FROM payment_allocations WHERE ${type === "invoice" ? "invoice_id" : "payment_id"} = ?`)
|
|
303
|
+
.run(id);
|
|
304
|
+
for (const invoice of touched)
|
|
305
|
+
settle(invoice);
|
|
306
|
+
});
|
|
307
|
+
return type === "invoice" ? invoiceDetail(ref) : paymentDetail(ref);
|
|
308
|
+
};
|
|
309
|
+
const limitOf = (limit) => Math.min(Math.max(limit ?? 100, 1), 500);
|
|
310
|
+
return {
|
|
311
|
+
addInvoice: (input) => {
|
|
312
|
+
const direction = oneOf(input.direction, INVOICE_DIRECTIONS, "an invoice's direction");
|
|
313
|
+
const currency = currencyOf(input.currency);
|
|
314
|
+
if (!Array.isArray(input.lines) || input.lines.length === 0 || input.lines.length > MAX_LINES)
|
|
315
|
+
refuse(`an invoice has 1–${MAX_LINES} lines`, "finance.lines");
|
|
316
|
+
const lines = input.lines.map((line, at) => {
|
|
317
|
+
const what = `line ${at + 1}`;
|
|
318
|
+
const quantity = quantityOf(line.quantity ?? "1");
|
|
319
|
+
const unitPrice = line.unitPrice === undefined ? null : minorUnits(line.unitPrice, currency, `${what}'s unit price`);
|
|
320
|
+
const computed = unitPrice === null ? null : lineUnits(quantity.thousandths, unitPrice);
|
|
321
|
+
const amount = line.amount === undefined ? computed : minorUnits(line.amount, currency, `${what}'s amount`);
|
|
322
|
+
if (amount === null)
|
|
323
|
+
return refuse(`${what} takes a unit price, an amount or both`, "finance.lines");
|
|
324
|
+
if (computed !== null && computed !== amount)
|
|
325
|
+
refuse(`${what}: ${quantity.text} × ${moneyLabel(unitPrice, currency)} is ` +
|
|
326
|
+
`${moneyLabel(computed, currency)}, not ${moneyLabel(amount, currency)}`, "finance.line-amount");
|
|
327
|
+
return {
|
|
328
|
+
description: text(line.description, `${what}'s description`, 500) ??
|
|
329
|
+
refuse(`${what} needs a description`, "finance.lines"),
|
|
330
|
+
quantity: quantity.text,
|
|
331
|
+
unitPrice: unitPrice ?? (quantity.thousandths === 1000n ? amount : null),
|
|
332
|
+
amount,
|
|
333
|
+
};
|
|
334
|
+
});
|
|
335
|
+
const total = lines.reduce((sum, line) => sum + line.amount, 0);
|
|
336
|
+
if (!Number.isSafeInteger(total))
|
|
337
|
+
refuse("an invoice's total is too large", "finance.total");
|
|
338
|
+
if (total <= 0)
|
|
339
|
+
refuse("an invoice totals more than 0", "finance.total");
|
|
340
|
+
if (input.total !== undefined) {
|
|
341
|
+
const printed = minorUnits(input.total, currency, "an invoice's total");
|
|
342
|
+
if (printed !== total)
|
|
343
|
+
refuse(`the invoice's total ${moneyLabel(printed, currency)} is not the sum of its lines, ${moneyLabel(total, currency)}`, "finance.total");
|
|
344
|
+
}
|
|
345
|
+
const number = text(input.number, "an invoice's number", 80);
|
|
346
|
+
const issueDate = isoDate(input.issueDate, "an invoice's issue date");
|
|
347
|
+
const dueDate = isoDate(input.dueDate, "an invoice's due date");
|
|
348
|
+
const id = write(database, () => {
|
|
349
|
+
const issuer = partyThing(input.issuer, "the issuer");
|
|
350
|
+
const counterparty = partyThing(input.counterparty, "the counterparty") ??
|
|
351
|
+
refuse("an invoice names its counterparty, a person: or organization:", "finance.party");
|
|
352
|
+
const by = written(input);
|
|
353
|
+
const at = now();
|
|
354
|
+
const made = Number(database
|
|
355
|
+
.prepare("INSERT INTO invoices (direction, number, issuer_type, issuer_id, counterparty_type, counterparty_id, project_id, " +
|
|
356
|
+
"currency, total, issue_date, due_date, status, review, author_type, author_id, model, confirmed_by_type, " +
|
|
357
|
+
"confirmed_by_id, source, metadata, created_at, updated_at) " +
|
|
358
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
|
|
359
|
+
.get(direction, number, issuer?.type ?? null, issuer?.id ?? null, counterparty.type, counterparty.id, by.project, currency, total, issueDate, dueDate, input.draft ? "draft" : "issued", by.review, by.actor.type, by.actor.id, by.model, by.confirmedType, by.confirmedId, by.source, by.metadata, at, at)?.id);
|
|
360
|
+
const insert = database.prepare("INSERT INTO invoice_lines (invoice_id, position, description, quantity, unit_price, amount, created_at, updated_at) " +
|
|
361
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
|
362
|
+
for (const [index, line] of lines.entries())
|
|
363
|
+
insert.run(made, index + 1, line.description, line.quantity, line.unitPrice, line.amount, at, at);
|
|
364
|
+
return made;
|
|
365
|
+
});
|
|
366
|
+
return invoiceDetail(`invoice:${id}`);
|
|
367
|
+
},
|
|
368
|
+
invoice: (reference) => invoiceDetail(reference),
|
|
369
|
+
invoices: (filter = {}) => {
|
|
370
|
+
const project = filter.project === undefined ? null : projectId(database, filter.project);
|
|
371
|
+
const counterparty = partyThing(filter.counterparty, "the counterparty");
|
|
372
|
+
const by = proposedBySql("i.author_type", "i.author_id", filter.proposedBy);
|
|
373
|
+
const status = filter.status === undefined ? null : oneOf(filter.status, INVOICE_STATUSES, "a status");
|
|
374
|
+
const review = filter.review === undefined ? null : oneOf(filter.review, FINANCE_REVIEWS, "a review");
|
|
375
|
+
return database
|
|
376
|
+
.prepare(`${INVOICES} WHERE i.deleted_at IS NULL AND (? IS NULL OR i.direction = ?) AND (? IS NULL OR i.status = ?) ` +
|
|
377
|
+
"AND (? IS NULL OR i.review = ?) AND (? IS NULL OR i.project_id = ?) " +
|
|
378
|
+
"AND (? IS NULL OR (i.counterparty_type = ? AND i.counterparty_id = ?)) " +
|
|
379
|
+
`${filter.open ? "AND i.status IN ('issued', 'partly_paid') " : ""}AND ${by.sql} ` +
|
|
380
|
+
"ORDER BY i.created_at DESC, i.id DESC LIMIT ?")
|
|
381
|
+
.all(filter.direction ?? null, filter.direction ?? null, status, status, review, review, project, project, counterparty?.type ?? null, counterparty?.type ?? null, counterparty?.id ?? null, ...by.params, limitOf(filter.limit))
|
|
382
|
+
.map(invoiceOf);
|
|
383
|
+
},
|
|
384
|
+
issueInvoice: (reference, by) => {
|
|
385
|
+
ownerOnly(by, "issues an invoice");
|
|
386
|
+
const row = rowOf(reference, "invoice");
|
|
387
|
+
if (row.status !== "draft")
|
|
388
|
+
refuse(`invoice:${row.id} is ${String(row.status)}, not a draft`, "finance.status");
|
|
389
|
+
write(database, () => {
|
|
390
|
+
database
|
|
391
|
+
.prepare("UPDATE invoices SET status = 'issued', updated_at = ? WHERE id = ?")
|
|
392
|
+
.run(now(), Number(row.id));
|
|
393
|
+
settle(Number(row.id));
|
|
394
|
+
});
|
|
395
|
+
return invoiceDetail(`invoice:${row.id}`);
|
|
396
|
+
},
|
|
397
|
+
voidInvoice: (reference, by) => {
|
|
398
|
+
ownerOnly(by, "voids an invoice");
|
|
399
|
+
const row = rowOf(reference, "invoice");
|
|
400
|
+
if (row.status === "void")
|
|
401
|
+
refuse(`invoice:${row.id} is already void`, "finance.status");
|
|
402
|
+
const allocated = database
|
|
403
|
+
.prepare("SELECT 1 FROM payment_allocations a JOIN payments p ON p.id = a.payment_id " +
|
|
404
|
+
"WHERE a.invoice_id = ? AND p.review = 'confirmed' AND p.deleted_at IS NULL LIMIT 1")
|
|
405
|
+
.get(Number(row.id));
|
|
406
|
+
if (allocated)
|
|
407
|
+
refuse(`payments are allocated to invoice:${row.id}; a paid invoice is not voided`, "finance.status");
|
|
408
|
+
write(database, () => {
|
|
409
|
+
database
|
|
410
|
+
.prepare("DELETE FROM payment_allocations WHERE invoice_id = ? " +
|
|
411
|
+
"AND payment_id IN (SELECT id FROM payments WHERE review = 'proposed')")
|
|
412
|
+
.run(Number(row.id));
|
|
413
|
+
database.prepare("UPDATE invoices SET status = 'void', updated_at = ? WHERE id = ?").run(now(), Number(row.id));
|
|
414
|
+
});
|
|
415
|
+
return invoiceDetail(`invoice:${row.id}`);
|
|
416
|
+
},
|
|
417
|
+
addPayment: (input) => {
|
|
418
|
+
const direction = oneOf(input.direction, MONEY_DIRECTIONS, "a payment's direction");
|
|
419
|
+
const currency = currencyOf(input.currency);
|
|
420
|
+
const amount = minorUnits(input.amount, currency, "a payment's amount");
|
|
421
|
+
if (amount <= 0)
|
|
422
|
+
refuse("a payment's amount is more than 0", "money.amount");
|
|
423
|
+
const date = isoDate(input.date, "a payment's date") ?? refuse("a payment has a date", "finance.field");
|
|
424
|
+
const reference = text(input.reference, "a payment's reference", 500);
|
|
425
|
+
const allocations = (input.allocations ?? []).map(({ invoice, amount: share }) => ({
|
|
426
|
+
invoice,
|
|
427
|
+
units: minorUnits(share, currency, `the amount allocated to ${invoice}`),
|
|
428
|
+
}));
|
|
429
|
+
const id = write(database, () => {
|
|
430
|
+
const payer = partyThing(input.payer, "the payer");
|
|
431
|
+
const payee = partyThing(input.payee, "the payee");
|
|
432
|
+
const by = written(input);
|
|
433
|
+
const at = now();
|
|
434
|
+
const made = database
|
|
435
|
+
.prepare("INSERT INTO payments (direction, payer_type, payer_id, payee_type, payee_id, amount, currency, payment_date, " +
|
|
436
|
+
"reference, project_id, review, author_type, author_id, model, confirmed_by_type, confirmed_by_id, source, " +
|
|
437
|
+
"metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *")
|
|
438
|
+
.get(direction, payer?.type ?? null, payer?.id ?? null, payee?.type ?? null, payee?.id ?? null, amount, currency, date, reference, by.project, by.review, by.actor.type, by.actor.id, by.model, by.confirmedType, by.confirmedId, by.source, by.metadata, at, at);
|
|
439
|
+
const touched = allocations.map(({ invoice, units }) => allocateRow(made, invoice, units, by.actor));
|
|
440
|
+
for (const invoice of touched)
|
|
441
|
+
settle(invoice);
|
|
442
|
+
return Number(made.id);
|
|
443
|
+
});
|
|
444
|
+
return paymentDetail(`payment:${id}`);
|
|
445
|
+
},
|
|
446
|
+
payment: (reference) => paymentDetail(reference),
|
|
447
|
+
payments: (filter = {}) => {
|
|
448
|
+
const project = filter.project === undefined ? null : projectId(database, filter.project);
|
|
449
|
+
const party = partyThing(filter.party, "the party");
|
|
450
|
+
const by = proposedBySql("p.author_type", "p.author_id", filter.proposedBy);
|
|
451
|
+
const review = filter.review === undefined ? null : oneOf(filter.review, FINANCE_REVIEWS, "a review");
|
|
452
|
+
return database
|
|
453
|
+
.prepare(`${PAYMENTS} WHERE p.deleted_at IS NULL AND (? IS NULL OR p.direction = ?) AND (? IS NULL OR p.review = ?) ` +
|
|
454
|
+
"AND (? IS NULL OR p.project_id = ?) AND (? IS NULL OR (p.payer_type = ? AND p.payer_id = ?) " +
|
|
455
|
+
`OR (p.payee_type = ? AND p.payee_id = ?)) AND ${by.sql} ORDER BY p.created_at DESC, p.id DESC LIMIT ?`)
|
|
456
|
+
.all(filter.direction ?? null, filter.direction ?? null, review, review, project, project, party?.type ?? null, party?.type ?? null, party?.id ?? null, party?.type ?? null, party?.id ?? null, ...by.params, limitOf(filter.limit))
|
|
457
|
+
.map(paymentOf);
|
|
458
|
+
},
|
|
459
|
+
allocate: ({ payment, invoice, amount, by }) => {
|
|
460
|
+
const row = rowOf(payment, "payment");
|
|
461
|
+
const ref = `payment:${row.id}`;
|
|
462
|
+
if (by !== "owner") {
|
|
463
|
+
const own = row.author_type === "bot" && row.author_name != null && botKey(String(row.author_name)) === botKey(by.bot);
|
|
464
|
+
if (row.review !== "proposed" || !own)
|
|
465
|
+
refuse(`only the owner allocates ${ref}; a bot allocates only from a payment it proposed that is still proposed`, "finance.owner-only", "permission_error");
|
|
466
|
+
}
|
|
467
|
+
if (row.review === "rejected" || row.deleted_at != null)
|
|
468
|
+
refuse(`${ref} is ${String(row.review)}`, "finance.allocate");
|
|
469
|
+
const currency = String(row.currency);
|
|
470
|
+
const units = minorUnits(amount, currency, "an allocation");
|
|
471
|
+
if (units <= 0)
|
|
472
|
+
refuse("an allocation is more than 0", "money.amount");
|
|
473
|
+
const invoiceId = write(database, () => {
|
|
474
|
+
const { actor } = authorActor(context, by);
|
|
475
|
+
const made = allocateRow(row, invoice, units, actor);
|
|
476
|
+
settle(made);
|
|
477
|
+
return made;
|
|
478
|
+
});
|
|
479
|
+
const found = allocationsWhere("payment_id", Number(row.id)).find((one) => one.invoice === `invoice:${invoiceId}`);
|
|
480
|
+
return found;
|
|
481
|
+
},
|
|
482
|
+
confirm: (reference, by, reason) => decide(reference, by, reason, true),
|
|
483
|
+
reject: (reference, by, reason) => decide(reference, by, reason, false),
|
|
484
|
+
openTotals: (filter = {}) => {
|
|
485
|
+
const project = filter.project === undefined ? null : projectId(database, filter.project);
|
|
486
|
+
const rows = database
|
|
487
|
+
.prepare(`SELECT i.direction, i.currency, sum(i.total - ${PAID}) AS open, count(*) AS n FROM invoices i ` +
|
|
488
|
+
"WHERE i.review = 'confirmed' AND i.deleted_at IS NULL AND i.status IN ('issued', 'partly_paid') " +
|
|
489
|
+
"AND (? IS NULL OR i.project_id = ?) GROUP BY i.direction, i.currency ORDER BY i.currency")
|
|
490
|
+
.all(project, project);
|
|
491
|
+
const totals = (direction) => rows
|
|
492
|
+
.filter((row) => row.direction === direction)
|
|
493
|
+
.map((row) => ({ ...moneyOf(Number(row.open), String(row.currency)), invoices: Number(row.n) }));
|
|
494
|
+
return { receivable: totals("receivable"), payable: totals("payable") };
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
};
|
|
498
|
+
export const financeStoreOver = (context) => {
|
|
499
|
+
const operations = financeOperationsOver(context);
|
|
500
|
+
return {
|
|
501
|
+
addInvoice: async (input) => operations.addInvoice(input),
|
|
502
|
+
invoice: async (reference) => operations.invoice(reference),
|
|
503
|
+
invoices: async (filter) => operations.invoices(filter),
|
|
504
|
+
issueInvoice: async (reference, by) => operations.issueInvoice(reference, by),
|
|
505
|
+
voidInvoice: async (reference, by) => operations.voidInvoice(reference, by),
|
|
506
|
+
addPayment: async (input) => operations.addPayment(input),
|
|
507
|
+
payment: async (reference) => operations.payment(reference),
|
|
508
|
+
payments: async (filter) => operations.payments(filter),
|
|
509
|
+
allocate: async (input) => operations.allocate(input),
|
|
510
|
+
confirm: async (reference, by, reason) => operations.confirm(reference, by, reason),
|
|
511
|
+
reject: async (reference, by, reason) => operations.reject(reference, by, reason),
|
|
512
|
+
openTotals: async (filter) => operations.openTotals(filter),
|
|
513
|
+
};
|
|
514
|
+
};
|
|
@@ -148,7 +148,9 @@ export const knowledgeStoreOver = (context) => {
|
|
|
148
148
|
parsed.type === "fact" ||
|
|
149
149
|
parsed.type === "bot" ||
|
|
150
150
|
parsed.type === "event" ||
|
|
151
|
-
parsed.type === "meeting"
|
|
151
|
+
parsed.type === "meeting" ||
|
|
152
|
+
parsed.type === "invoice" ||
|
|
153
|
+
parsed.type === "payment")
|
|
152
154
|
return undefined;
|
|
153
155
|
return { type: parsed.type, id: parsed.id };
|
|
154
156
|
};
|
|
@@ -59,6 +59,8 @@ export const MANIFEST = [
|
|
|
59
59
|
{ name: "20261101011859_version-43-memory-project", version: 43, minCompatible: 34 },
|
|
60
60
|
{ name: "20261102050533_version-44-relationship-purpose", version: 44, minCompatible: 34 },
|
|
61
61
|
{ name: "20261102050534_version-44-suggested-links-move", version: 44, minCompatible: 34 },
|
|
62
|
+
{ name: "20261102173102_version-45-finance", version: 45, minCompatible: 34 },
|
|
63
|
+
{ name: "20261102173113_version-45-finance-rules", version: 45, minCompatible: 34 },
|
|
62
64
|
];
|
|
63
65
|
export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
|
|
64
66
|
const migrations = [];
|
|
@@ -889,5 +889,40 @@ export const GENERATED = [
|
|
|
889
889
|
"statements": [
|
|
890
890
|
"-- A suggestion's references (chat:, msg:) need the store's own encoding, which SQL cannot write, so the next writable\n-- open moves the unconfirmed agent and rule links into `proposed_actions` and deletes this key (moveSuggestedLinks).\nINSERT OR REPLACE INTO store_settings (key, value, at) VALUES ('suggested_links_to_move', '1', unixepoch() * 1000);"
|
|
891
891
|
]
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
"name": "20261102173102_version-45-finance",
|
|
895
|
+
"statements": [
|
|
896
|
+
"CREATE TABLE `invoice_lines` (\n\t`id` integer PRIMARY KEY,\n\t`invoice_id` integer NOT NULL,\n\t`position` integer NOT NULL,\n\t`description` text NOT NULL,\n\t`quantity` text NOT NULL,\n\t`unit_price` integer,\n\t`amount` integer NOT NULL,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\tCONSTRAINT `fk_invoice_lines_invoice_id_invoices_id_fk` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`),\n\tCONSTRAINT `invoice_lines_invoice_id_position_unique` UNIQUE(`invoice_id`,`position`)\n);",
|
|
897
|
+
"CREATE TABLE `invoices` (\n\t`id` integer PRIMARY KEY,\n\t`direction` text NOT NULL,\n\t`number` text,\n\t`issuer_type` text,\n\t`issuer_id` integer,\n\t`counterparty_type` text NOT NULL,\n\t`counterparty_id` integer NOT NULL,\n\t`project_id` integer,\n\t`currency` text NOT NULL,\n\t`total` integer NOT NULL,\n\t`issue_date` text,\n\t`due_date` text,\n\t`status` text NOT NULL,\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_invoices_project_id_projects_id_fk` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`)\n);",
|
|
898
|
+
"CREATE TABLE `payment_allocations` (\n\t`id` integer PRIMARY KEY,\n\t`payment_id` integer NOT NULL,\n\t`invoice_id` integer NOT NULL,\n\t`amount` integer NOT NULL,\n\t`author_type` text NOT NULL,\n\t`author_id` integer NOT NULL,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\tCONSTRAINT `fk_payment_allocations_payment_id_payments_id_fk` FOREIGN KEY (`payment_id`) REFERENCES `payments`(`id`),\n\tCONSTRAINT `fk_payment_allocations_invoice_id_invoices_id_fk` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`),\n\tCONSTRAINT `payment_allocations_payment_id_invoice_id_unique` UNIQUE(`payment_id`,`invoice_id`)\n);",
|
|
899
|
+
"CREATE TABLE `payments` (\n\t`id` integer PRIMARY KEY,\n\t`direction` text NOT NULL,\n\t`payer_type` text,\n\t`payer_id` integer,\n\t`payee_type` text,\n\t`payee_id` integer,\n\t`amount` integer NOT NULL,\n\t`currency` text NOT NULL,\n\t`payment_date` text NOT NULL,\n\t`reference` text,\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_payments_project_id_projects_id_fk` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`)\n);",
|
|
900
|
+
"CREATE INDEX `invoices_by_issuer_type_issuer_id` ON `invoices` (`issuer_type`,`issuer_id`);",
|
|
901
|
+
"CREATE INDEX `invoices_by_counterparty_type_counterparty_id` ON `invoices` (`counterparty_type`,`counterparty_id`);",
|
|
902
|
+
"CREATE INDEX `invoices_by_project_id` ON `invoices` (`project_id`);",
|
|
903
|
+
"CREATE INDEX `invoices_by_author_type_author_id` ON `invoices` (`author_type`,`author_id`);",
|
|
904
|
+
"CREATE INDEX `invoices_by_confirmed_by_type_confirmed_by_id` ON `invoices` (`confirmed_by_type`,`confirmed_by_id`);",
|
|
905
|
+
"CREATE INDEX `payment_allocations_by_invoice_id` ON `payment_allocations` (`invoice_id`);",
|
|
906
|
+
"CREATE INDEX `payment_allocations_by_author_type_author_id` ON `payment_allocations` (`author_type`,`author_id`);",
|
|
907
|
+
"CREATE INDEX `payments_by_payer_type_payer_id` ON `payments` (`payer_type`,`payer_id`);",
|
|
908
|
+
"CREATE INDEX `payments_by_payee_type_payee_id` ON `payments` (`payee_type`,`payee_id`);",
|
|
909
|
+
"CREATE INDEX `payments_by_project_id` ON `payments` (`project_id`);",
|
|
910
|
+
"CREATE INDEX `payments_by_author_type_author_id` ON `payments` (`author_type`,`author_id`);",
|
|
911
|
+
"CREATE INDEX `payments_by_confirmed_by_type_confirmed_by_id` ON `payments` (`confirmed_by_type`,`confirmed_by_id`);"
|
|
912
|
+
]
|
|
913
|
+
},
|
|
914
|
+
{
|
|
915
|
+
"name": "20261102173113_version-45-finance-rules",
|
|
916
|
+
"statements": [
|
|
917
|
+
"-- 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 money that does not add up. An invoice's limit counts confirmed payments only, so an agent's\n-- pending proposal never blocks the owner; confirming a payment checks the limit again.\nCREATE TRIGGER invoices_valid_bi BEFORE INSERT ON invoices\n WHEN new.direction NOT IN ('receivable', 'payable') OR new.total < 0\n OR new.status NOT IN ('draft', 'issued', 'partly_paid', 'paid', 'void')\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected') BEGIN\n SELECT RAISE(ABORT, 'invoices: direction receivable or payable, a total of 0 or more, a known status and review');\nEND;",
|
|
918
|
+
"CREATE TRIGGER invoices_valid_bu BEFORE UPDATE ON invoices\n WHEN new.direction NOT IN ('receivable', 'payable') OR new.total < 0\n OR new.status NOT IN ('draft', 'issued', 'partly_paid', 'paid', 'void')\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected')\n OR (new.currency IS NOT old.currency AND EXISTS (SELECT 1 FROM payment_allocations WHERE invoice_id = new.id))\n OR new.total < (SELECT coalesce(sum(a.amount), 0) FROM payment_allocations a JOIN payments p ON p.id = a.payment_id\n WHERE a.invoice_id = new.id AND p.review = 'confirmed' AND p.deleted_at IS NULL) BEGIN\n SELECT RAISE(ABORT, 'invoices: the total stays at or above what is allocated to it, in the same currency');\nEND;",
|
|
919
|
+
"CREATE TRIGGER invoice_lines_valid_bi BEFORE INSERT ON invoice_lines\n WHEN new.amount < 0 OR new.unit_price < 0 BEGIN\n SELECT RAISE(ABORT, 'invoice_lines: an amount and a unit price of 0 or more');\nEND;",
|
|
920
|
+
"CREATE TRIGGER invoice_lines_valid_bu BEFORE UPDATE ON invoice_lines\n WHEN new.amount < 0 OR new.unit_price < 0 BEGIN\n SELECT RAISE(ABORT, 'invoice_lines: an amount and a unit price of 0 or more');\nEND;",
|
|
921
|
+
"CREATE TRIGGER payments_valid_bi BEFORE INSERT ON payments\n WHEN new.direction NOT IN ('in', 'out') OR new.amount <= 0\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected') BEGIN\n SELECT RAISE(ABORT, 'payments: direction in or out, an amount above 0 and a known review');\nEND;",
|
|
922
|
+
"CREATE TRIGGER payments_valid_bu BEFORE UPDATE ON payments\n WHEN new.direction NOT IN ('in', 'out') OR new.amount <= 0\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected')\n OR (new.currency IS NOT old.currency AND EXISTS (SELECT 1 FROM payment_allocations WHERE payment_id = new.id))\n OR new.amount < (SELECT coalesce(sum(amount), 0) FROM payment_allocations WHERE payment_id = new.id) BEGIN\n SELECT RAISE(ABORT, 'payments: the amount stays at or above what is allocated from it, in the same currency');\nEND;",
|
|
923
|
+
"CREATE TRIGGER payments_confirm_bu BEFORE UPDATE OF review ON payments\n WHEN new.review = 'confirmed' AND old.review IS NOT 'confirmed' AND EXISTS (\n SELECT 1 FROM payment_allocations a JOIN invoices i ON i.id = a.invoice_id WHERE a.payment_id = new.id\n AND a.amount + (SELECT coalesce(sum(o.amount), 0) FROM payment_allocations o JOIN payments p ON p.id = o.payment_id\n WHERE o.invoice_id = a.invoice_id AND o.payment_id <> new.id AND p.review = 'confirmed' AND p.deleted_at IS NULL)\n > i.total) BEGIN\n SELECT RAISE(ABORT, 'payments: confirming it would settle more than an invoice''s total');\nEND;",
|
|
924
|
+
"CREATE TRIGGER payment_allocations_valid_bi BEFORE INSERT ON payment_allocations\n WHEN new.amount <= 0\n OR (SELECT currency FROM payments WHERE id = new.payment_id)\n IS NOT (SELECT currency FROM invoices WHERE id = new.invoice_id)\n OR (SELECT coalesce(sum(amount), 0) FROM payment_allocations WHERE payment_id = new.payment_id) + new.amount\n > (SELECT amount FROM payments WHERE id = new.payment_id)\n OR (SELECT coalesce(sum(a.amount), 0) FROM payment_allocations a JOIN payments p ON p.id = a.payment_id\n WHERE a.invoice_id = new.invoice_id\n AND ((p.review = 'confirmed' AND p.deleted_at IS NULL) OR a.payment_id = new.payment_id)) + new.amount\n > (SELECT total FROM invoices WHERE id = new.invoice_id) BEGIN\n SELECT RAISE(ABORT, 'payment_allocations: above 0, one currency, within the payment and within the invoice');\nEND;",
|
|
925
|
+
"CREATE TRIGGER payment_allocations_valid_bu BEFORE UPDATE ON payment_allocations\n WHEN new.amount <= 0 OR new.payment_id IS NOT old.payment_id OR new.invoice_id IS NOT old.invoice_id\n OR (SELECT coalesce(sum(amount), 0) FROM payment_allocations WHERE payment_id = new.payment_id AND id <> old.id)\n + new.amount > (SELECT amount FROM payments WHERE id = new.payment_id)\n OR (SELECT coalesce(sum(a.amount), 0) FROM payment_allocations a JOIN payments p ON p.id = a.payment_id\n WHERE a.invoice_id = new.invoice_id AND a.id <> old.id\n AND ((p.review = 'confirmed' AND p.deleted_at IS NULL) OR a.payment_id = new.payment_id)) + new.amount\n > (SELECT total FROM invoices WHERE id = new.invoice_id) BEGIN\n SELECT RAISE(ABORT, 'payment_allocations: above 0, within the payment and within the invoice, sides unchanged');\nEND;"
|
|
926
|
+
]
|
|
892
927
|
}
|
|
893
928
|
];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { invoiceLine, paymentLine } from "../../domain/finance.js";
|
|
1
2
|
import { filesAccountSql, providerLabel, providerNamed } from "../../domain/providers.js";
|
|
2
3
|
import { oneLine } from "../../domain/ref-summaries.js";
|
|
3
4
|
import { formatReference, parseReference } from "../../domain/references.js";
|
|
@@ -181,6 +182,23 @@ const READERS = {
|
|
|
181
182
|
body: bodyOr(body, "x.statement"),
|
|
182
183
|
})),
|
|
183
184
|
bot: byId("bot", "bots", () => ({ title: "x.name" })),
|
|
185
|
+
// Money is formatted with its currency's scale in JS, so SQL hands `titleOf` the fields as JSON.
|
|
186
|
+
invoice: byId("invoice", "invoices", () => ({
|
|
187
|
+
title: "json_object('id', x.id, 'number', x.number, 'direction', x.direction, 'total', x.total, " +
|
|
188
|
+
`'currency', x.currency, 'counterpartyName', ${subjectName("x.counterparty_type", "x.counterparty_id")}, ` +
|
|
189
|
+
"'dueDate', x.due_date, 'status', x.status, 'review', x.review)",
|
|
190
|
+
holder: "(SELECT name FROM projects WHERE id = x.project_id)",
|
|
191
|
+
at: "x.created_at",
|
|
192
|
+
deleted: "x.deleted_at",
|
|
193
|
+
})),
|
|
194
|
+
payment: byId("payment", "payments", () => ({
|
|
195
|
+
title: "json_object('direction', x.direction, 'amount', x.amount, 'currency', x.currency, 'partyName', " +
|
|
196
|
+
`CASE x.direction WHEN 'in' THEN ${subjectName("x.payer_type", "x.payer_id")} ` +
|
|
197
|
+
`ELSE ${subjectName("x.payee_type", "x.payee_id")} END, 'date', x.payment_date, 'review', x.review)`,
|
|
198
|
+
holder: "(SELECT name FROM projects WHERE id = x.project_id)",
|
|
199
|
+
at: "x.created_at",
|
|
200
|
+
deleted: "x.deleted_at",
|
|
201
|
+
})),
|
|
184
202
|
};
|
|
185
203
|
/** Mail saved before the mail tables existed is still messages, so a miss there is read again as a message. */
|
|
186
204
|
const FALLBACK = { email: "message", email_thread: "chat" };
|
|
@@ -262,6 +280,17 @@ const refOfAccount = (reference) => reference.type === "message" || reference.ty
|
|
|
262
280
|
: reference.type === "contact"
|
|
263
281
|
? { provider: reference.provider, account: null }
|
|
264
282
|
: { provider: null, account: null };
|
|
283
|
+
/** A summary never throws: fields that no longer parse, after `flat` made them one line, read as no title. */
|
|
284
|
+
const fieldsOf = (json, line) => {
|
|
285
|
+
if (json === null)
|
|
286
|
+
return null;
|
|
287
|
+
try {
|
|
288
|
+
return line(JSON.parse(json));
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
265
294
|
const titleOf = (kind, reference, found) => {
|
|
266
295
|
switch (kind) {
|
|
267
296
|
case "message": {
|
|
@@ -271,6 +300,10 @@ const titleOf = (kind, reference, found) => {
|
|
|
271
300
|
case "memory":
|
|
272
301
|
case "fact":
|
|
273
302
|
return cut(found.title, MEMORY_TITLE_CHARS);
|
|
303
|
+
case "invoice":
|
|
304
|
+
return fieldsOf(found.title, invoiceLine);
|
|
305
|
+
case "payment":
|
|
306
|
+
return fieldsOf(found.title, paymentLine);
|
|
274
307
|
case "folder":
|
|
275
308
|
return reference.type === "folder" && reference.path !== null && found.title !== null
|
|
276
309
|
? `${found.title}/${reference.path}`
|