@wirecat/cli-core 0.556.0 → 0.558.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 +37 -0
- package/dist/calendar/mcp/index.d.ts +12 -12
- package/dist/cli/index.d.ts +0 -1
- package/dist/cli/index.js +0 -1
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- 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/fact.d.ts +1 -12
- package/dist/entities/fact.js +0 -13
- package/dist/entities/index.d.ts +2 -12
- package/dist/entities/meeting-findings.d.ts +1 -1
- package/dist/errors/reasons.js +16 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mail/cli/review.js +0 -1
- package/dist/meetings/findings-rules.d.ts +7 -4
- package/dist/meetings/findings-rules.js +11 -4
- package/dist/meetings/mcp/index.d.ts +15 -15
- package/dist/meetings/payment.d.ts +10 -6
- package/dist/meetings/payment.js +21 -6
- package/dist/services/project-status/index.d.ts +1 -1
- package/dist/services/project-status/index.js +7 -3
- package/dist/services/project-status/signals/payments.d.ts +3 -3
- package/dist/services/project-status/signals/payments.js +43 -17
- 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/store/evidence-findings.js +16 -8
- package/dist/store/index.d.ts +2 -1
- package/dist/store/project-transfer/request.d.ts +1 -1
- package/dist/store/project-transfer/schema.d.ts +2 -2
- package/dist/store/project-transfer/snapshot-model.d.ts +2 -2
- package/dist/store/sqlite/entity-types.d.ts +2 -0
- package/dist/store/sqlite/entity-types.js +2 -0
- package/dist/store/sqlite/facts.d.ts +0 -13
- package/dist/store/sqlite/facts.js +5 -58
- package/dist/store/sqlite/finance.d.ts +203 -0
- package/dist/store/sqlite/finance.js +529 -0
- package/dist/store/sqlite/findings-core.js +5 -14
- package/dist/store/sqlite/knowledge.js +3 -1
- package/dist/store/sqlite/manifest.js +3 -0
- package/dist/store/sqlite/migrations.generated.js +43 -0
- package/dist/store/sqlite/open-store.js +2 -0
- package/dist/store/sqlite/payment-entries.d.ts +26 -0
- package/dist/store/sqlite/payment-entries.js +91 -0
- package/dist/store/sqlite/payment-facts.d.ts +12 -0
- package/dist/store/sqlite/payment-facts.js +92 -0
- package/dist/store/sqlite/project-transfer/snapshot.d.ts +1 -1
- 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/things.js +2 -0
- package/dist/store/store.d.ts +3 -1
- package/dist/store/store.js +5 -0
- package/package.json +1 -1
- package/dist/cli/payment-query.d.ts +0 -8
- package/dist/cli/payment-query.js +0 -27
|
@@ -889,5 +889,48 @@ 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
|
+
]
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
"name": "20261102185954_version-46-payment-facts",
|
|
930
|
+
"statements": [
|
|
931
|
+
"-- Money lives in `payments` and `invoices`, not facts. An older build still takes a payment fact, so the triggers\n-- refuse one; the facts already stored need the finance code's minor units and rules, so the next writable open moves\n-- them and deletes this key (movePaymentFacts).\nCREATE TRIGGER facts_no_payment_bi BEFORE INSERT ON facts WHEN new.type = 'payment' BEGIN\n SELECT RAISE(ABORT, 'facts: a payment is a payments row in store.finance, not a fact; upgrade the CLI');\nEND;",
|
|
932
|
+
"CREATE TRIGGER facts_no_payment_bu BEFORE UPDATE OF type ON facts WHEN new.type = 'payment' BEGIN\n SELECT RAISE(ABORT, 'facts: a payment is a payments row in store.finance, not a fact; upgrade the CLI');\nEND;",
|
|
933
|
+
"INSERT OR REPLACE INTO store_settings (key, value, at) VALUES ('payment_facts_to_move', '1', unixepoch() * 1000);"
|
|
934
|
+
]
|
|
892
935
|
}
|
|
893
936
|
];
|
|
@@ -12,6 +12,7 @@ import { fillFolded } from "./folded.js";
|
|
|
12
12
|
import { fillJoinKeys } from "./join-keys.js";
|
|
13
13
|
import { logPruner } from "./log-pruning.js";
|
|
14
14
|
import { openSqlite } from "./open.js";
|
|
15
|
+
import { movePaymentFacts } from "./payment-facts.js";
|
|
15
16
|
import { loadProviders } from "./providers.js";
|
|
16
17
|
import { assertReadCompatible } from "./read-compatibility.js";
|
|
17
18
|
import { stemsState } from "./stems.js";
|
|
@@ -57,6 +58,7 @@ export const openStoreContext = async ({ path, env, now = Date.now, command, ver
|
|
|
57
58
|
fillFolded(database);
|
|
58
59
|
fillJoinKeys(database);
|
|
59
60
|
moveSuggestedLinks(database, now);
|
|
61
|
+
movePaymentFacts({ ...opened, now, decisions });
|
|
60
62
|
const pending = pendingNormalization(database);
|
|
61
63
|
if (pending > 0 && pending <= BACKFILL_ON_OPEN)
|
|
62
64
|
backfillNormalized(database);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Payment } from "../../meetings/index.js";
|
|
2
|
+
import type { Decider } from "../decisions.js";
|
|
3
|
+
import type { Author } from "./memories.js";
|
|
4
|
+
import type { StoreContext } from "./open.js";
|
|
5
|
+
/** A payment as an agent's finding or a retired payment fact holds it. */
|
|
6
|
+
export interface PaymentEntry {
|
|
7
|
+
payment: Payment;
|
|
8
|
+
statement?: string;
|
|
9
|
+
project?: string;
|
|
10
|
+
evidence: string[];
|
|
11
|
+
/** The `invoice:` a paid entry settles or a cancelled one voids. */
|
|
12
|
+
supersedes?: string;
|
|
13
|
+
}
|
|
14
|
+
/** What becomes of the proposal: confirmed or rejected by the decider, or left proposed. */
|
|
15
|
+
export type PaymentOutcome = {
|
|
16
|
+
confirm: boolean;
|
|
17
|
+
by: Decider;
|
|
18
|
+
} | "proposed";
|
|
19
|
+
/** The finding's two-decimal amount in the currency's own scale: "1200.00" JPY is "1200", "1200.50" JPY is refused. */
|
|
20
|
+
export declare const entryAmount: (payment: Payment) => string;
|
|
21
|
+
/**
|
|
22
|
+
* Money from a payment finding goes to `store.finance`, never into a fact: a paid one is a payment, allocated to the
|
|
23
|
+
* invoice it supersedes as far as that is open; an expected one is an invoice of one line; a cancelled one voids its
|
|
24
|
+
* invoice, which only the owner does. The author proposes; `outcome` is the owner's decision on it.
|
|
25
|
+
*/
|
|
26
|
+
export declare const paymentEntriesOver: (context: StoreContext) => (entry: PaymentEntry, by: Author, outcome: PaymentOutcome) => string;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { formatDecimal, minorUnits, parseDecimal, scaleOf } from "../../domain/money.js";
|
|
3
|
+
import { paymentTitle } from "../../meetings/index.js";
|
|
4
|
+
import { financeOperationsOver } from "./finance.js";
|
|
5
|
+
const refuse = (message, reason) => {
|
|
6
|
+
throw new CliError("validation_error", message, { reason });
|
|
7
|
+
};
|
|
8
|
+
/** The finding's two-decimal amount in the currency's own scale: "1200.00" JPY is "1200", "1200.50" JPY is refused. */
|
|
9
|
+
export const entryAmount = (payment) => {
|
|
10
|
+
const scale = scaleOf(payment.currency);
|
|
11
|
+
const cents = parseDecimal(payment.amount, 2);
|
|
12
|
+
if (cents === undefined)
|
|
13
|
+
return refuse(`${payment.amount} is not an amount`, "money.amount");
|
|
14
|
+
const shift = 10n ** BigInt(Math.abs(scale - 2));
|
|
15
|
+
if (scale >= 2)
|
|
16
|
+
return formatDecimal(cents * shift, scale);
|
|
17
|
+
if (cents % shift !== 0n)
|
|
18
|
+
return refuse(`${payment.currency} has ${scale} decimals; ${payment.amount} does not fit`, "money.amount");
|
|
19
|
+
return formatDecimal(cents / shift, scale);
|
|
20
|
+
};
|
|
21
|
+
const party = (payment) => /^(person|organization):/.test(payment.counterparty ?? "") ? payment.counterparty : null;
|
|
22
|
+
/**
|
|
23
|
+
* Money from a payment finding goes to `store.finance`, never into a fact: a paid one is a payment, allocated to the
|
|
24
|
+
* invoice it supersedes as far as that is open; an expected one is an invoice of one line; a cancelled one voids its
|
|
25
|
+
* invoice, which only the owner does. The author proposes; `outcome` is the owner's decision on it.
|
|
26
|
+
*/
|
|
27
|
+
export const paymentEntriesOver = (context) => {
|
|
28
|
+
const finance = financeOperationsOver(context);
|
|
29
|
+
const today = () => new Date(context.now()).toISOString().slice(0, 10);
|
|
30
|
+
const decided = (ref, outcome) => {
|
|
31
|
+
if (outcome === "proposed")
|
|
32
|
+
return ref;
|
|
33
|
+
return (outcome.confirm ? finance.confirm(ref, outcome.by) : finance.reject(ref, outcome.by)).ref;
|
|
34
|
+
};
|
|
35
|
+
/** As much of the payment as the invoice still has open; nothing when it is already covered. */
|
|
36
|
+
const settled = (reference, amount, currency) => {
|
|
37
|
+
const invoice = finance.invoice(reference);
|
|
38
|
+
const units = Math.min(minorUnits(amount, currency), minorUnits(invoice.open, invoice.currency));
|
|
39
|
+
return units > 0 ? [{ invoice: invoice.ref, amount: formatDecimal(units, scaleOf(currency)) }] : [];
|
|
40
|
+
};
|
|
41
|
+
return (entry, by, outcome) => {
|
|
42
|
+
const { payment } = entry;
|
|
43
|
+
const amount = entryAmount(payment);
|
|
44
|
+
const project = entry.project ?? (payment.counterparty?.startsWith("project:") ? payment.counterparty : undefined);
|
|
45
|
+
const written = {
|
|
46
|
+
by,
|
|
47
|
+
...(project === undefined ? {} : { project }),
|
|
48
|
+
...(by === "owner" ? {} : { source: "agent", reason: entry.statement ?? paymentTitle(entry) }),
|
|
49
|
+
evidence: entry.evidence,
|
|
50
|
+
};
|
|
51
|
+
const counterparty = party(payment);
|
|
52
|
+
if (payment.state === "cancelled") {
|
|
53
|
+
if (entry.supersedes === undefined)
|
|
54
|
+
return refuse("a cancelled payment names the invoice it cancels", "finance.ref");
|
|
55
|
+
if (outcome === "proposed" || !outcome.confirm)
|
|
56
|
+
return entry.supersedes;
|
|
57
|
+
if (outcome.by !== "owner")
|
|
58
|
+
throw new CliError("permission_error", "only the owner voids an invoice", { reason: "finance.owner-only" });
|
|
59
|
+
return finance.voidInvoice(entry.supersedes, "owner").ref;
|
|
60
|
+
}
|
|
61
|
+
if (payment.state === "expected") {
|
|
62
|
+
if (counterparty === null)
|
|
63
|
+
return refuse("an expected payment is an invoice, which names a person: or organization:", "finance.party");
|
|
64
|
+
const invoice = finance.addInvoice({
|
|
65
|
+
...written,
|
|
66
|
+
direction: payment.direction === "to_us" ? "receivable" : "payable",
|
|
67
|
+
counterparty,
|
|
68
|
+
currency: payment.currency,
|
|
69
|
+
...(payment.due_at === null ? {} : { dueDate: payment.due_at }),
|
|
70
|
+
lines: [{ description: (payment.item ?? entry.statement ?? paymentTitle(entry)).slice(0, 500), amount }],
|
|
71
|
+
});
|
|
72
|
+
return decided(invoice.ref, outcome);
|
|
73
|
+
}
|
|
74
|
+
const settles = entry.supersedes === undefined ? [] : settled(entry.supersedes, amount, payment.currency);
|
|
75
|
+
const made = finance.addPayment({
|
|
76
|
+
...written,
|
|
77
|
+
direction: payment.direction === "to_us" ? "in" : "out",
|
|
78
|
+
...(counterparty === null
|
|
79
|
+
? {}
|
|
80
|
+
: payment.direction === "to_us"
|
|
81
|
+
? { payer: counterparty }
|
|
82
|
+
: { payee: counterparty }),
|
|
83
|
+
amount,
|
|
84
|
+
currency: payment.currency,
|
|
85
|
+
date: payment.paid_at ?? payment.due_at ?? today(),
|
|
86
|
+
...(payment.item === null ? {} : { reference: payment.item }),
|
|
87
|
+
allocations: settles,
|
|
88
|
+
});
|
|
89
|
+
return decided(made.ref, outcome);
|
|
90
|
+
};
|
|
91
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { StoreContext } from "./open.js";
|
|
2
|
+
/** Set by store version 46 and deleted once the move ran. */
|
|
3
|
+
export declare const PAYMENT_FACTS_TO_MOVE = "payment_facts_to_move";
|
|
4
|
+
/**
|
|
5
|
+
* Moves the payment facts store versions 44–45 took into `store.finance`, as a payment finding is recorded now: a
|
|
6
|
+
* paid one a payment, an expected one an invoice, a cancelled one voids the invoice it supersedes. An accepted fact
|
|
7
|
+
* becomes a confirmed row and a proposed one stays proposed; a rejected one, or one a correction superseded, is
|
|
8
|
+
* rejected. An expected fact that a payment or a cancellation superseded held until then, so its invoice is
|
|
9
|
+
* confirmed and the payment settles it. What cannot be a row (no party for an invoice, a pending cancellation, an
|
|
10
|
+
* owner's fact that no longer holds) stays a `statement` fact with the fields under `metadata.retired_payment`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const movePaymentFacts: (context: StoreContext) => number;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { validatePayment } from "../../meetings/index.js";
|
|
2
|
+
import { dropLinksOf, evidenceOf, keepHighestId } from "./memories.js";
|
|
3
|
+
import { paymentEntriesOver } from "./payment-entries.js";
|
|
4
|
+
import { write } from "./transaction.js";
|
|
5
|
+
/** Set by store version 46 and deleted once the move ran. */
|
|
6
|
+
export const PAYMENT_FACTS_TO_MOVE = "payment_facts_to_move";
|
|
7
|
+
const paymentOf = (metadata) => {
|
|
8
|
+
try {
|
|
9
|
+
return validatePayment(JSON.parse(String(metadata)).payment);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Moves the payment facts store versions 44–45 took into `store.finance`, as a payment finding is recorded now: a
|
|
17
|
+
* paid one a payment, an expected one an invoice, a cancelled one voids the invoice it supersedes. An accepted fact
|
|
18
|
+
* becomes a confirmed row and a proposed one stays proposed; a rejected one, or one a correction superseded, is
|
|
19
|
+
* rejected. An expected fact that a payment or a cancellation superseded held until then, so its invoice is
|
|
20
|
+
* confirmed and the payment settles it. What cannot be a row (no party for an invoice, a pending cancellation, an
|
|
21
|
+
* owner's fact that no longer holds) stays a `statement` fact with the fields under `metadata.retired_payment`.
|
|
22
|
+
*/
|
|
23
|
+
export const movePaymentFacts = (context) => {
|
|
24
|
+
const { database, now } = context;
|
|
25
|
+
if (!database.prepare("SELECT 1 AS found FROM store_settings WHERE key = ?").get(PAYMENT_FACTS_TO_MOVE))
|
|
26
|
+
return 0;
|
|
27
|
+
const record = paymentEntriesOver(context);
|
|
28
|
+
return write(database, () => {
|
|
29
|
+
const rows = database
|
|
30
|
+
.prepare("SELECT f.*, b.name AS bot_name, b.kind AS bot_kind FROM facts f " +
|
|
31
|
+
"LEFT JOIN bots b ON f.author_type = 'bot' AND b.id = f.author_id WHERE f.type = 'payment' ORDER BY f.id")
|
|
32
|
+
.all();
|
|
33
|
+
const nextState = (id) => paymentOf(database
|
|
34
|
+
.prepare("SELECT metadata FROM facts WHERE supersedes_id = ? AND type = 'payment' ORDER BY id DESC LIMIT 1")
|
|
35
|
+
.get(id)?.metadata)?.state;
|
|
36
|
+
const outcomeOf = (row, payment) => {
|
|
37
|
+
if (row.status === "proposed")
|
|
38
|
+
return "proposed";
|
|
39
|
+
if (row.status === "accepted")
|
|
40
|
+
return { confirm: true, by: "owner" };
|
|
41
|
+
const settled = row.status === "superseded" && payment.state === "expected" && nextState(Number(row.id));
|
|
42
|
+
return { confirm: settled === "paid" || settled === "cancelled", by: "owner" };
|
|
43
|
+
};
|
|
44
|
+
const moved = new Map();
|
|
45
|
+
for (const row of rows) {
|
|
46
|
+
const id = Number(row.id);
|
|
47
|
+
const payment = paymentOf(row.metadata);
|
|
48
|
+
if (!payment)
|
|
49
|
+
continue;
|
|
50
|
+
const by = row.author_type === "bot" && row.bot_name != null
|
|
51
|
+
? {
|
|
52
|
+
bot: String(row.bot_name),
|
|
53
|
+
kind: row.bot_kind === "rule" ? "rule" : "agent",
|
|
54
|
+
...(row.model == null ? {} : { model: String(row.model) }),
|
|
55
|
+
}
|
|
56
|
+
: "owner";
|
|
57
|
+
const outcome = outcomeOf(row, payment);
|
|
58
|
+
const holds = outcome !== "proposed" && outcome.confirm;
|
|
59
|
+
if ((by === "owner" && !holds) || (payment.state === "cancelled" && !holds))
|
|
60
|
+
continue;
|
|
61
|
+
const target = row.supersedes_id == null ? undefined : moved.get(Number(row.supersedes_id));
|
|
62
|
+
const entry = {
|
|
63
|
+
payment,
|
|
64
|
+
statement: String(row.statement),
|
|
65
|
+
...(row.project_id == null ? {} : { project: `project:${row.project_id}` }),
|
|
66
|
+
evidence: evidenceOf(context, { type: "fact", id }),
|
|
67
|
+
...(payment.state !== "expected" && target?.startsWith("invoice:") ? { supersedes: target } : {}),
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
moved.set(id, write(database, () => record(entry, by, by === "owner" ? "proposed" : outcome)));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Kept below as a statement: an unknown currency or a party that is gone is no reason to lose the fact.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (moved.size)
|
|
77
|
+
keepHighestId(database, "facts", now());
|
|
78
|
+
for (const id of moved.keys()) {
|
|
79
|
+
database.prepare("UPDATE facts SET supersedes_id = NULL WHERE supersedes_id = ?").run(id);
|
|
80
|
+
dropLinksOf(database, { type: "fact", id });
|
|
81
|
+
database.prepare("DELETE FROM links WHERE from_type = 'fact' AND from_id = ?").run(id);
|
|
82
|
+
database.prepare("DELETE FROM taggings WHERE taggable_type = 'fact' AND taggable_id = ?").run(id);
|
|
83
|
+
database.prepare("DELETE FROM facts WHERE id = ?").run(id);
|
|
84
|
+
}
|
|
85
|
+
database
|
|
86
|
+
.prepare("UPDATE facts SET type = 'statement', updated_at = ?, metadata = json_remove(json_set(coalesce(metadata, '{}'), " +
|
|
87
|
+
"'$.retired_payment', json(json_extract(metadata, '$.payment'))), '$.payment') WHERE type = 'payment'")
|
|
88
|
+
.run(now());
|
|
89
|
+
database.prepare("DELETE FROM store_settings WHERE key = ?").run(PAYMENT_FACTS_TO_MOVE);
|
|
90
|
+
return moved.size;
|
|
91
|
+
});
|
|
92
|
+
};
|
|
@@ -5,7 +5,7 @@ export declare const projectAnnotationSnapshot: (database: CacheDatabase, projec
|
|
|
5
5
|
format: "wirecat.project-annotations";
|
|
6
6
|
facts: {
|
|
7
7
|
ref: string;
|
|
8
|
-
type: "decision" | "fact" | "
|
|
8
|
+
type: "decision" | "fact" | "promise" | "question" | "statement";
|
|
9
9
|
statement: string;
|
|
10
10
|
status: "accepted" | "proposed" | "rejected" | "reversed" | "superseded";
|
|
11
11
|
subject: string | null;
|
|
@@ -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}`
|