@wirecat/cli-core 0.557.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 +16 -0
- package/dist/cli/index.d.ts +0 -1
- package/dist/cli/index.js +0 -1
- 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 +1 -0
- package/dist/meetings/findings-rules.d.ts +7 -4
- package/dist/meetings/findings-rules.js +11 -4
- 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/store/evidence-findings.js +16 -8
- package/dist/store/index.d.ts +1 -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/facts.d.ts +0 -13
- package/dist/store/sqlite/facts.js +5 -58
- package/dist/store/sqlite/finance.d.ts +4 -0
- package/dist/store/sqlite/finance.js +19 -4
- package/dist/store/sqlite/findings-core.js +5 -14
- package/dist/store/sqlite/manifest.js +1 -0
- package/dist/store/sqlite/migrations.generated.js +8 -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/store.d.ts +0 -1
- package/package.json +1 -1
- package/dist/cli/payment-query.d.ts +0 -8
- package/dist/cli/payment-query.js +0 -27
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.558.0 — 02.11.2026
|
|
11
|
+
|
|
12
|
+
### Changed — may break callers
|
|
13
|
+
|
|
14
|
+
- **A payment is no longer a fact (store version 46).** Money lives only in `store.finance`. `facts.add` refuses type
|
|
15
|
+
`payment` with reason `fact.payment-retired`, naming `store.finance.addPayment`, and a trigger refuses one from an
|
|
16
|
+
older build. `FACT_TYPES`, the `fact` entity and `Fact` lose `payment`; `PaymentFilter`, `facts.list({ payment })`,
|
|
17
|
+
`PAYMENT_QUERY_FIELDS` and `paymentFilterOf` are gone. The first writable open moves stored payment facts into rows.
|
|
18
|
+
- **An accepted payment finding writes a finance row, not a fact.** A paid one is a `payment:` row, an expected one
|
|
19
|
+
an `invoice:` of one line, proposed by the agent and confirmed by the owner's acceptance. `supersedes` now names
|
|
20
|
+
the `invoice:` a paid one settles or a cancelled one voids, not a `fact:`. An expected one needs a `person:` or
|
|
21
|
+
`organization:` counterparty, and the amount must fit the currency's decimals. New optional field `paid_at`.
|
|
22
|
+
- **`projects status` reads money from `store.finance`.** The `payments` signal (version 3) lists payments and open
|
|
23
|
+
invoices by their `payment:` and `invoice:` refs, `status` being `proposed` or `confirmed`; the open total counts
|
|
24
|
+
confirmed invoices only. Finance rows gain `evidence`, and `addInvoice` and `addPayment` take it.
|
|
25
|
+
|
|
10
26
|
## 0.557.0 — 02.11.2026
|
|
11
27
|
|
|
12
28
|
### Added
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -111,7 +111,6 @@ export { type Saving, stored } from "./messenger/stored.js";
|
|
|
111
111
|
export { dateOrNone } from "./messenger/tasks-command.js";
|
|
112
112
|
export { MODEL_CATALOGUE_QUERY, type ModelCatalogueOptions } from "./model-catalogue-command.js";
|
|
113
113
|
export { positiveCount, window, withPaging } from "./paging.js";
|
|
114
|
-
export { PAYMENT_QUERY_FIELDS, paymentFilterOf } from "./payment-query.js";
|
|
115
114
|
export { type CommandPermissions, commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, type PermissionKeyOf, type PermissionKeySource, refuseUnknownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
|
|
116
115
|
export { asFirstWord, commandWords, liftProfile, profileFree, refuseCommandName, rootOf, takesProfile, usableProfileName, } from "./profile.js";
|
|
117
116
|
export { createProgram, type ProgramDefinition, type ProgramOptions, type RunOptions, run } from "./program.js";
|
package/dist/cli/index.js
CHANGED
|
@@ -101,7 +101,6 @@ export { stored } from "./messenger/stored.js";
|
|
|
101
101
|
export { dateOrNone } from "./messenger/tasks-command.js";
|
|
102
102
|
export { MODEL_CATALOGUE_QUERY } from "./model-catalogue-command.js";
|
|
103
103
|
export { positiveCount, window, withPaging } from "./paging.js";
|
|
104
|
-
export { PAYMENT_QUERY_FIELDS, paymentFilterOf } from "./payment-query.js";
|
|
105
104
|
export { commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, refuseUnknownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
|
|
106
105
|
export { asFirstWord, commandWords, liftProfile, profileFree, refuseCommandName, rootOf, takesProfile, usableProfileName, } from "./profile.js";
|
|
107
106
|
export { createProgram, run } from "./program.js";
|
package/dist/entities/fact.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as v from "valibot";
|
|
|
2
2
|
export declare const fact: v.StrictObjectSchema<{
|
|
3
3
|
readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
|
|
4
4
|
readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
|
|
5
|
-
readonly kind: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"
|
|
5
|
+
readonly kind: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"], undefined>;
|
|
6
6
|
readonly text: v.StringSchema<undefined>;
|
|
7
7
|
readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed", "rejected"], undefined>;
|
|
8
8
|
readonly project: v.NullableSchema<v.StringSchema<undefined>, undefined>;
|
|
@@ -29,17 +29,6 @@ export declare const fact: v.StrictObjectSchema<{
|
|
|
29
29
|
/** Who recorded it: the owner, or an agent whose fact waits as proposed. */
|
|
30
30
|
readonly origin: v.PicklistSchema<["owner", "agent"], undefined>;
|
|
31
31
|
readonly evidence: v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
|
|
32
|
-
/** A `payment` fact's fields. */
|
|
33
|
-
readonly payment: v.OptionalSchema<v.NullableSchema<v.StrictObjectSchema<{
|
|
34
|
-
readonly amount: v.StringSchema<undefined>;
|
|
35
|
-
readonly currency: v.StringSchema<undefined>;
|
|
36
|
-
readonly direction: v.PicklistSchema<readonly ["to_us", "from_us"], undefined>;
|
|
37
|
-
readonly counterparty: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
|
|
38
|
-
readonly counterparty_name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
|
|
39
|
-
readonly due_at: v.NullableSchema<v.StringSchema<undefined>, undefined>;
|
|
40
|
-
readonly state: v.PicklistSchema<readonly ["expected", "paid", "cancelled"], undefined>;
|
|
41
|
-
readonly item: v.NullableSchema<v.StringSchema<undefined>, undefined>;
|
|
42
|
-
}, undefined>, undefined>, undefined>;
|
|
43
32
|
/** With `--sources`: each piece of evidence read back. */
|
|
44
33
|
readonly sources: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
|
|
45
34
|
readonly ref: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
|
package/dist/entities/fact.js
CHANGED
|
@@ -1,17 +1,6 @@
|
|
|
1
1
|
import * as v from "valibot";
|
|
2
2
|
import { FACT_STATUSES, FACT_TYPES, MEMORY_SCOPES } from "../domain/knowledge.js";
|
|
3
|
-
import { PAYMENT_DIRECTIONS, PAYMENT_STATES } from "../meetings/index.js";
|
|
4
3
|
import { author, evidenceSource, id, reference, text, time } from "./fields.js";
|
|
5
|
-
const payment = v.strictObject({
|
|
6
|
-
amount: text,
|
|
7
|
-
currency: text,
|
|
8
|
-
direction: v.picklist(PAYMENT_DIRECTIONS),
|
|
9
|
-
counterparty: v.nullable(reference),
|
|
10
|
-
counterparty_name: v.nullable(text),
|
|
11
|
-
due_at: v.nullable(text),
|
|
12
|
-
state: v.picklist(PAYMENT_STATES),
|
|
13
|
-
item: v.nullable(text),
|
|
14
|
-
});
|
|
15
4
|
export const fact = v.strictObject({
|
|
16
5
|
ref: reference,
|
|
17
6
|
id,
|
|
@@ -35,8 +24,6 @@ export const fact = v.strictObject({
|
|
|
35
24
|
/** Who recorded it: the owner, or an agent whose fact waits as proposed. */
|
|
36
25
|
origin: v.picklist(["owner", "agent"]),
|
|
37
26
|
evidence: v.array(reference),
|
|
38
|
-
/** A `payment` fact's fields. */
|
|
39
|
-
payment: v.optional(v.nullable(payment)),
|
|
40
27
|
/** With `--sources`: each piece of evidence read back. */
|
|
41
28
|
sources: v.optional(v.array(evidenceSource)),
|
|
42
29
|
created_at: time,
|
package/dist/entities/index.d.ts
CHANGED
|
@@ -5761,7 +5761,7 @@ export declare const ENTITIES: {
|
|
|
5761
5761
|
readonly fact: v.StrictObjectSchema<{
|
|
5762
5762
|
readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
|
|
5763
5763
|
readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
|
|
5764
|
-
readonly kind: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"
|
|
5764
|
+
readonly kind: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"], undefined>;
|
|
5765
5765
|
readonly text: v.StringSchema<undefined>;
|
|
5766
5766
|
readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed", "rejected"], undefined>;
|
|
5767
5767
|
readonly project: v.NullableSchema<v.StringSchema<undefined>, undefined>;
|
|
@@ -5784,16 +5784,6 @@ export declare const ENTITIES: {
|
|
|
5784
5784
|
readonly scope: v.NullableSchema<v.PicklistSchema<readonly ["personal", "work"], undefined>, undefined>;
|
|
5785
5785
|
readonly origin: v.PicklistSchema<["owner", "agent"], undefined>;
|
|
5786
5786
|
readonly evidence: v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
|
|
5787
|
-
readonly payment: v.OptionalSchema<v.NullableSchema<v.StrictObjectSchema<{
|
|
5788
|
-
readonly amount: v.StringSchema<undefined>;
|
|
5789
|
-
readonly currency: v.StringSchema<undefined>;
|
|
5790
|
-
readonly direction: v.PicklistSchema<readonly ["to_us", "from_us"], undefined>;
|
|
5791
|
-
readonly counterparty: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
|
|
5792
|
-
readonly counterparty_name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
|
|
5793
|
-
readonly due_at: v.NullableSchema<v.StringSchema<undefined>, undefined>;
|
|
5794
|
-
readonly state: v.PicklistSchema<readonly ["expected", "paid", "cancelled"], undefined>;
|
|
5795
|
-
readonly item: v.NullableSchema<v.StringSchema<undefined>, undefined>;
|
|
5796
|
-
}, undefined>, undefined>, undefined>;
|
|
5797
5787
|
readonly sources: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
|
|
5798
5788
|
readonly ref: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
|
|
5799
5789
|
readonly kind: v.StringSchema<undefined>;
|
|
@@ -6589,7 +6579,7 @@ export declare const ENTITIES: {
|
|
|
6589
6579
|
readonly fingerprint: v.StringSchema<undefined>;
|
|
6590
6580
|
}, undefined>, undefined>;
|
|
6591
6581
|
readonly kind: v.LiteralSchema<"fact", undefined>;
|
|
6592
|
-
readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"
|
|
6582
|
+
readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"], undefined>;
|
|
6593
6583
|
readonly statement: v.StringSchema<undefined>;
|
|
6594
6584
|
}, undefined>, v.StrictObjectSchema<{
|
|
6595
6585
|
readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
|
|
@@ -17,7 +17,7 @@ export declare const meetingFindings: v.StrictObjectSchema<{
|
|
|
17
17
|
readonly fingerprint: v.StringSchema<undefined>;
|
|
18
18
|
}, undefined>, undefined>;
|
|
19
19
|
readonly kind: v.LiteralSchema<"fact", undefined>;
|
|
20
|
-
readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"
|
|
20
|
+
readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"], undefined>;
|
|
21
21
|
readonly statement: v.StringSchema<undefined>;
|
|
22
22
|
}, undefined>, v.StrictObjectSchema<{
|
|
23
23
|
readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
|
package/dist/errors/reasons.js
CHANGED
|
@@ -138,6 +138,7 @@ const OTHERS = [
|
|
|
138
138
|
validation("input.required", "the command needs an answer and cannot ask for one"),
|
|
139
139
|
validation("input.too-large", "the input is over its size limit"),
|
|
140
140
|
["meeting-project.stale", "configuration_error", "the meeting's project changed meanwhile"],
|
|
141
|
+
validation("fact.payment-retired", "a payment is a payments row in store.finance, not a fact"),
|
|
141
142
|
validation("finance.allocate", "the invoice or payment cannot take an allocation in its state"),
|
|
142
143
|
validation("finance.currency", "the payment and the invoice are in different currencies; nothing is converted"),
|
|
143
144
|
validation("finance.field", "a field of the invoice or payment is not valid"),
|
|
@@ -14,16 +14,19 @@ interface FindingBase {
|
|
|
14
14
|
}
|
|
15
15
|
export interface FactFinding extends FindingBase {
|
|
16
16
|
kind: "fact";
|
|
17
|
-
type:
|
|
17
|
+
type: (typeof FACT_TYPES)[number];
|
|
18
18
|
statement: string;
|
|
19
19
|
}
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* A payment an agent read. Confirming it writes money through `store.finance`, never a fact: a paid one a `payments`
|
|
22
|
+
* row, an expected one an invoice; a paid one that supersedes an invoice settles it, a cancelled one voids it.
|
|
23
|
+
*/
|
|
21
24
|
export interface PaymentFinding extends FindingBase {
|
|
22
25
|
kind: "payment";
|
|
23
|
-
/** The
|
|
26
|
+
/** The full sentence, kept as the proposal's reason; without one, the payment's title. */
|
|
24
27
|
statement?: string;
|
|
25
28
|
payment: Payment;
|
|
26
|
-
/** The `
|
|
29
|
+
/** The `invoice:` of the expected payment this one settles (paid) or voids (cancelled). */
|
|
27
30
|
supersedes?: string;
|
|
28
31
|
}
|
|
29
32
|
export interface TaskFinding extends FindingBase {
|
|
@@ -70,20 +70,27 @@ export const validateFindings = (input, rules) => {
|
|
|
70
70
|
if (finding.kind === "fact") {
|
|
71
71
|
fields(finding, ["id", "project", "subject", "evidence", "kind", "type", "statement"], rules.singular);
|
|
72
72
|
if (finding.type === "payment")
|
|
73
|
-
return invalid("a payment is a finding of kind payment");
|
|
73
|
+
return invalid("a payment is a finding of kind payment; money is a payments row, not a fact");
|
|
74
74
|
if (!FACT_TYPES.includes(finding.type))
|
|
75
75
|
return invalid("unknown fact finding type");
|
|
76
76
|
return { ...base, kind: "fact", type: finding.type, statement: text(finding.statement, 2000) };
|
|
77
77
|
}
|
|
78
78
|
if (finding.kind === "payment" && rules.payments) {
|
|
79
79
|
fields(finding, ["id", "project", "subject", "evidence", "kind", "statement", "payment", "supersedes"], rules.singular);
|
|
80
|
-
if (finding.supersedes !== undefined && !/^
|
|
81
|
-
return invalid("a payment finding supersedes
|
|
80
|
+
if (finding.supersedes !== undefined && !/^invoice:[1-9]\d*$/.test(String(finding.supersedes)))
|
|
81
|
+
return invalid("a payment finding supersedes an invoice: reference, the expected payment it settles or voids");
|
|
82
|
+
const payment = validatePayment(finding.payment);
|
|
83
|
+
if (payment.state === "expected" && finding.supersedes !== undefined)
|
|
84
|
+
return invalid("an expected payment supersedes nothing; a paid or cancelled one supersedes its invoice");
|
|
85
|
+
if (payment.state === "cancelled" && finding.supersedes === undefined)
|
|
86
|
+
return invalid("a cancelled payment names the invoice: it cancels in supersedes");
|
|
87
|
+
if (payment.state === "expected" && !/^(person|organization):/.test(payment.counterparty ?? ""))
|
|
88
|
+
return invalid("an expected payment becomes an invoice, which names its counterparty as a person: or organization:");
|
|
82
89
|
return {
|
|
83
90
|
...base,
|
|
84
91
|
kind: "payment",
|
|
85
92
|
...(finding.statement === undefined ? {} : { statement: text(finding.statement, 2000) }),
|
|
86
|
-
payment
|
|
93
|
+
payment,
|
|
87
94
|
...(finding.supersedes === undefined ? {} : { supersedes: finding.supersedes }),
|
|
88
95
|
};
|
|
89
96
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
/** Every fact type the store takes; findings and meeting changes check against the same list. */
|
|
2
|
-
export declare const FACT_TYPES: readonly ["fact", "statement", "question", "decision", "promise"
|
|
1
|
+
/** Every fact type the store takes; findings and meeting changes check against the same list. Money is not a fact. */
|
|
2
|
+
export declare const FACT_TYPES: readonly ["fact", "statement", "question", "decision", "promise"];
|
|
3
3
|
export declare const PAYMENT_DIRECTIONS: readonly ["to_us", "from_us"];
|
|
4
4
|
export declare const PAYMENT_STATES: readonly ["expected", "paid", "cancelled"];
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* A payment finding's fields, as an agent reads them from evidence. Confirmed by the owner, a paid one becomes a
|
|
7
|
+
* `payments` row, an expected one an invoice, a cancelled one voids the invoice it supersedes.
|
|
8
|
+
*/
|
|
6
9
|
export interface Payment {
|
|
7
10
|
/** A decimal as text with two places, "50000.00": money is never a float. */
|
|
8
11
|
amount: string;
|
|
@@ -14,14 +17,15 @@ export interface Payment {
|
|
|
14
17
|
counterparty_name: string | null;
|
|
15
18
|
/** An ISO date. */
|
|
16
19
|
due_at: string | null;
|
|
17
|
-
/** A change of state is a new fact that supersedes this one, so the history and its evidence stay. */
|
|
18
20
|
state: (typeof PAYMENT_STATES)[number];
|
|
19
|
-
/** What it is for, short: "support, September". The
|
|
21
|
+
/** What it is for, short: "support, September". The finding's statement is the full sentence. */
|
|
20
22
|
item: string | null;
|
|
23
|
+
/** When a paid one was paid, an ISO date; without it the due date, else the day the owner confirms it. */
|
|
24
|
+
paid_at?: string;
|
|
21
25
|
}
|
|
22
26
|
/** A payment as stored: the amount with two decimals, the currency upper case; refuses what is not one. */
|
|
23
27
|
export declare const validatePayment: (input: unknown) => Payment;
|
|
24
|
-
/** One line for a payment, as a list or an inbox shows it: "1 200 EUR to us from Ana, due 31.10 (expected)". */
|
|
28
|
+
/** One line for a payment finding, as a list or an inbox shows it: "1 200 EUR to us from Ana, due 31.10 (expected)". */
|
|
25
29
|
export declare const paymentTitle: (fact: {
|
|
26
30
|
payment: Payment | null;
|
|
27
31
|
}) => string;
|
package/dist/meetings/payment.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
-
/** Every fact type the store takes; findings and meeting changes check against the same list. */
|
|
3
|
-
export const FACT_TYPES = ["fact", "statement", "question", "decision", "promise"
|
|
2
|
+
/** Every fact type the store takes; findings and meeting changes check against the same list. Money is not a fact. */
|
|
3
|
+
export const FACT_TYPES = ["fact", "statement", "question", "decision", "promise"];
|
|
4
4
|
export const PAYMENT_DIRECTIONS = ["to_us", "from_us"];
|
|
5
5
|
export const PAYMENT_STATES = ["expected", "paid", "cancelled"];
|
|
6
6
|
const invalid = (message) => {
|
|
@@ -21,7 +21,7 @@ const amountOf = (value) => {
|
|
|
21
21
|
const dateOf = (value) => {
|
|
22
22
|
const date = typeof value === "string" ? value.trim() : "";
|
|
23
23
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) !== date)
|
|
24
|
-
return invalid("a payment's
|
|
24
|
+
return invalid("a payment's dates are ISO dates, such as 2026-10-31");
|
|
25
25
|
return date;
|
|
26
26
|
};
|
|
27
27
|
const optionalText = (value, what, limit) => {
|
|
@@ -36,7 +36,17 @@ const oneOf = (value, allowed, what) => {
|
|
|
36
36
|
return invalid(`a payment's ${what} is one of ${allowed.join(", ")}`);
|
|
37
37
|
return value;
|
|
38
38
|
};
|
|
39
|
-
const FIELDS = [
|
|
39
|
+
const FIELDS = [
|
|
40
|
+
"amount",
|
|
41
|
+
"currency",
|
|
42
|
+
"direction",
|
|
43
|
+
"counterparty",
|
|
44
|
+
"counterparty_name",
|
|
45
|
+
"due_at",
|
|
46
|
+
"state",
|
|
47
|
+
"item",
|
|
48
|
+
"paid_at",
|
|
49
|
+
];
|
|
40
50
|
/** A payment as stored: the amount with two decimals, the currency upper case; refuses what is not one. */
|
|
41
51
|
export const validatePayment = (input) => {
|
|
42
52
|
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
@@ -51,6 +61,9 @@ export const validatePayment = (input) => {
|
|
|
51
61
|
const counterparty = optionalText(raw.counterparty, "counterparty", 80);
|
|
52
62
|
if (counterparty !== null && !COUNTERPARTY.test(counterparty))
|
|
53
63
|
return invalid("a payment's counterparty is a person:, organization: or project: reference");
|
|
64
|
+
const state = oneOf(raw.state, PAYMENT_STATES, "state");
|
|
65
|
+
if (raw.paid_at != null && state !== "paid")
|
|
66
|
+
return invalid("only a paid payment has paid_at");
|
|
54
67
|
return {
|
|
55
68
|
amount: amountOf(raw.amount),
|
|
56
69
|
currency,
|
|
@@ -58,8 +71,10 @@ export const validatePayment = (input) => {
|
|
|
58
71
|
counterparty,
|
|
59
72
|
counterparty_name: optionalText(raw.counterparty_name, "counterparty name", 200),
|
|
60
73
|
due_at: raw.due_at === undefined || raw.due_at === null ? null : dateOf(raw.due_at),
|
|
61
|
-
state
|
|
74
|
+
state,
|
|
62
75
|
item: optionalText(raw.item, "item", 200),
|
|
76
|
+
// Left out when absent, so a finding staged before the field keeps its fingerprint.
|
|
77
|
+
...(raw.paid_at == null ? {} : { paid_at: dateOf(raw.paid_at) }),
|
|
63
78
|
};
|
|
64
79
|
};
|
|
65
80
|
const grouped = (amount) => {
|
|
@@ -67,7 +82,7 @@ const grouped = (amount) => {
|
|
|
67
82
|
const digits = whole.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
|
68
83
|
return cents === "00" ? digits : `${digits}.${cents}`;
|
|
69
84
|
};
|
|
70
|
-
/** One line for a payment, as a list or an inbox shows it: "1 200 EUR to us from Ana, due 31.10 (expected)". */
|
|
85
|
+
/** One line for a payment finding, as a list or an inbox shows it: "1 200 EUR to us from Ana, due 31.10 (expected)". */
|
|
71
86
|
export const paymentTitle = (fact) => {
|
|
72
87
|
const payment = fact.payment;
|
|
73
88
|
if (!payment)
|
|
@@ -60,7 +60,7 @@ export interface ProjectStatus {
|
|
|
60
60
|
chats: StatusChatSummary[];
|
|
61
61
|
/** Every signal's items, in signal order, then chat order. */
|
|
62
62
|
items: StatusItem[];
|
|
63
|
-
/** Open amounts and
|
|
63
|
+
/** Open amounts and what confirmed open invoices still owe, summed per direction and currency. */
|
|
64
64
|
money: {
|
|
65
65
|
open: MoneyTotal[];
|
|
66
66
|
};
|
|
@@ -53,11 +53,15 @@ const childrenOf = async (store, root, catalog) => {
|
|
|
53
53
|
};
|
|
54
54
|
const inAccounts = (accounts, provider, account) => accounts === undefined || accounts.some((one) => one.provider === provider && one.account === account);
|
|
55
55
|
const failure = (error) => (error instanceof Error ? error.message : String(error));
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* An open ask, or a confirmed open invoice; an ask that a confirmed row cites is counted once, as the row. A proposed
|
|
58
|
+
* row promises nothing yet, so the ask it cites still counts.
|
|
59
|
+
*/
|
|
57
60
|
const counted = (items) => {
|
|
58
|
-
const
|
|
61
|
+
const confirmed = (item) => item.signal === "payments" && item.data.status === "confirmed";
|
|
62
|
+
const recorded = new Set(items.filter(confirmed).flatMap((item) => item.evidence.slice(1).map((one) => one.ref)));
|
|
59
63
|
return (item, state) => item.signal === "payments"
|
|
60
|
-
? state === "expected"
|
|
64
|
+
? state === "expected" && confirmed(item)
|
|
61
65
|
: item.signal === "amounts" && state === "open" && !item.evidence.some((one) => recorded.has(one.ref));
|
|
62
66
|
};
|
|
63
67
|
const totalsOf = (items) => {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Payment } from "../../../meetings/index.js";
|
|
2
2
|
import type { StatusSignal } from "../types.js";
|
|
3
3
|
export interface PaymentsData extends Payment {
|
|
4
4
|
title: string;
|
|
5
|
-
/** The
|
|
5
|
+
/** The row's review: proposed or confirmed. */
|
|
6
6
|
status: string;
|
|
7
7
|
}
|
|
8
|
-
/**
|
|
8
|
+
/** Payments and open invoices of the project and its children from `store.finance`, proposed or confirmed. */
|
|
9
9
|
export declare const payments: StatusSignal<PaymentsData>;
|
|
@@ -1,24 +1,50 @@
|
|
|
1
|
-
|
|
2
|
-
const
|
|
3
|
-
|
|
1
|
+
const holding = (row) => row.review !== "rejected";
|
|
2
|
+
const item = (row, at, data) => ({
|
|
3
|
+
signal: "payments",
|
|
4
|
+
chat: null,
|
|
5
|
+
at,
|
|
6
|
+
who: null,
|
|
7
|
+
// The row first, then what it cites, so an amount read from a cited message is not counted twice.
|
|
8
|
+
evidence: [row.ref, ...row.evidence].map((ref) => ({ ref, fingerprint: null })),
|
|
9
|
+
basis: "store",
|
|
10
|
+
data: { ...data, title: row.title, status: row.review },
|
|
11
|
+
});
|
|
12
|
+
const paid = (row) => {
|
|
13
|
+
const party = row.direction === "in" ? row.payer : row.payee;
|
|
14
|
+
return item(row, `${row.date}T00:00:00.000Z`, {
|
|
15
|
+
amount: row.amount,
|
|
16
|
+
currency: row.currency,
|
|
17
|
+
direction: row.direction === "in" ? "to_us" : "from_us",
|
|
18
|
+
counterparty: party?.ref ?? null,
|
|
19
|
+
counterparty_name: party?.name ?? null,
|
|
20
|
+
due_at: null,
|
|
21
|
+
state: "paid",
|
|
22
|
+
item: row.reference,
|
|
23
|
+
paid_at: row.date,
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
/** What an open invoice still owes is the expected payment. */
|
|
27
|
+
const expected = (row) => item(row, row.issueDate === null ? row.createdAt : `${row.issueDate}T00:00:00.000Z`, {
|
|
28
|
+
amount: row.open,
|
|
29
|
+
currency: row.currency,
|
|
30
|
+
direction: row.direction === "receivable" ? "to_us" : "from_us",
|
|
31
|
+
counterparty: row.counterparty.ref,
|
|
32
|
+
counterparty_name: row.counterparty.name,
|
|
33
|
+
due_at: row.dueDate,
|
|
34
|
+
state: "expected",
|
|
35
|
+
item: row.number,
|
|
36
|
+
});
|
|
37
|
+
/** Payments and open invoices of the project and its children from `store.finance`, proposed or confirmed. */
|
|
4
38
|
export const payments = {
|
|
5
39
|
id: "payments",
|
|
6
|
-
version:
|
|
40
|
+
version: 3,
|
|
7
41
|
scope: "project",
|
|
8
42
|
read: async (context) => {
|
|
9
43
|
const projects = [context.project.ref, ...context.project.children.map((child) => child.ref)];
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
chat: null,
|
|
16
|
-
at: fact.occurredAt ?? fact.createdAt,
|
|
17
|
-
who: null,
|
|
18
|
-
// The fact first, then what it cites, so an amount read from a cited message is not counted twice.
|
|
19
|
-
evidence: [fact.ref, ...fact.evidence].map((ref) => ({ ref, fingerprint: null })),
|
|
20
|
-
basis: "fact",
|
|
21
|
-
data: { ...fact.payment, title: paymentTitle(fact), status: fact.status },
|
|
22
|
-
}));
|
|
44
|
+
const rows = await Promise.all(projects.map(async (project) => [
|
|
45
|
+
...(await context.store.finance.payments({ project, limit: 500 })).filter(holding).map(paid),
|
|
46
|
+
...(await context.store.finance.invoices({ project, open: true, limit: 500 })).filter(holding).map(expected),
|
|
47
|
+
]));
|
|
48
|
+
return rows.flat();
|
|
23
49
|
},
|
|
24
50
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { formatLocator, parseLocator } from "../domain/locator.js";
|
|
3
3
|
import { validateFindings } from "../meetings/index.js";
|
|
4
|
+
import { entryAmount } from "./sqlite/payment-entries.js";
|
|
4
5
|
export const EVIDENCE_FINDINGS_KIND = "evidence_findings";
|
|
5
6
|
const reference = (raw) => {
|
|
6
7
|
if (/^(document|note):[1-9]\d*$/.test(raw))
|
|
@@ -12,11 +13,18 @@ const reference = (raw) => {
|
|
|
12
13
|
throw new CliError("validation_error", "finding evidence is a msg:, document: or note: reference");
|
|
13
14
|
}
|
|
14
15
|
};
|
|
15
|
-
export const validateEvidenceFindings = (input) =>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
export const validateEvidenceFindings = (input) => {
|
|
17
|
+
const valid = validateFindings(input, {
|
|
18
|
+
plural: "evidence findings",
|
|
19
|
+
singular: "evidence finding",
|
|
20
|
+
cited: "reference",
|
|
21
|
+
reference,
|
|
22
|
+
anySubject: true,
|
|
23
|
+
payments: true,
|
|
24
|
+
});
|
|
25
|
+
// Refused at staging rather than when the owner accepts, which would fail the whole batch.
|
|
26
|
+
for (const finding of valid.findings)
|
|
27
|
+
if (finding.kind === "payment")
|
|
28
|
+
entryAmount(finding.payment);
|
|
29
|
+
return valid;
|
|
30
|
+
};
|
package/dist/store/index.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export type { ChatMetadata } from "./sqlite/chats.js";
|
|
|
36
36
|
export type { Email, EmailAddress, EmailAttachment, EmailFacts, EmailFilter, EmailInput, EmailInThread, EmailLocationInput, EmailRecipient, EmailRef, EmailThread, EmailVectorHit, FolderEmail, Mailbox, MailboxInput, MailFilter, MailStore, PartialEmail, SenderScore, ThreadDetails, ThreadSave, } from "./sqlite/emails.js";
|
|
37
37
|
export { EVENT_FIELDS, type EventChunkToEmbed, type EventHit, type EventQuery, type EventSearch, type NearestEvent, } from "./sqlite/event-search.js";
|
|
38
38
|
export type { EvidenceSource } from "./sqlite/evidence-sources.js";
|
|
39
|
-
export type { Fact, FactInput, FactsStore
|
|
39
|
+
export type { Fact, FactInput, FactsStore } from "./sqlite/facts.js";
|
|
40
40
|
export type { FileListQuery, FilesStore, StoredFile } from "./sqlite/files.js";
|
|
41
41
|
export type { Allocation, FinanceStore, Invoice, InvoiceDetail, InvoiceInput, InvoiceLine, InvoiceLineInput, OpenTotal, Party, Payment as FinancePayment, PaymentDetail as FinancePaymentDetail, PaymentInput as FinancePaymentInput, } from "./sqlite/finance.js";
|
|
42
42
|
export { IMPORTED_DOCUMENT_QUERY, type ImportedDocuments } from "./sqlite/imported-documents.js";
|
|
@@ -4,7 +4,7 @@ export declare const projectAnnotationRequest: (request: ProjectAnnotationTarget
|
|
|
4
4
|
format: "wirecat.project-annotations";
|
|
5
5
|
facts: {
|
|
6
6
|
ref: string;
|
|
7
|
-
type: "decision" | "fact" | "
|
|
7
|
+
type: "decision" | "fact" | "promise" | "question" | "statement";
|
|
8
8
|
statement: string;
|
|
9
9
|
status: "accepted" | "proposed" | "rejected" | "reversed" | "superseded";
|
|
10
10
|
subject: string | null;
|
|
@@ -23,7 +23,7 @@ export declare const projectAnnotationsSchema: v.StrictObjectSchema<{
|
|
|
23
23
|
readonly aliases: v.SchemaWithPipe<readonly [v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>, v.MaxLengthAction<string[], 1000, undefined>]>;
|
|
24
24
|
readonly facts: v.SchemaWithPipe<readonly [v.ArraySchema<v.StrictObjectSchema<{
|
|
25
25
|
readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
|
|
26
|
-
readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"
|
|
26
|
+
readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"], undefined>;
|
|
27
27
|
readonly statement: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
|
|
28
28
|
readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed", "rejected"], undefined>;
|
|
29
29
|
readonly subject: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
|
|
@@ -38,7 +38,7 @@ export declare const projectAnnotationsSchema: v.StrictObjectSchema<{
|
|
|
38
38
|
readonly updated_at: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.SafeIntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
|
|
39
39
|
}, undefined>, undefined>, v.MaxLengthAction<{
|
|
40
40
|
ref: string;
|
|
41
|
-
type: "decision" | "fact" | "
|
|
41
|
+
type: "decision" | "fact" | "promise" | "question" | "statement";
|
|
42
42
|
statement: string;
|
|
43
43
|
status: "accepted" | "proposed" | "rejected" | "reversed" | "superseded";
|
|
44
44
|
subject: string | null;
|
|
@@ -4,7 +4,7 @@ export declare const projectAnnotationSnapshotSchema: v.StrictObjectSchema<{
|
|
|
4
4
|
readonly format: v.LiteralSchema<"wirecat.project-annotations", undefined>;
|
|
5
5
|
readonly facts: v.SchemaWithPipe<readonly [v.ArraySchema<v.StrictObjectSchema<{
|
|
6
6
|
readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
|
|
7
|
-
readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"
|
|
7
|
+
readonly type: v.PicklistSchema<readonly ["fact", "statement", "question", "decision", "promise"], undefined>;
|
|
8
8
|
readonly statement: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
|
|
9
9
|
readonly status: v.PicklistSchema<readonly ["proposed", "accepted", "superseded", "reversed", "rejected"], undefined>;
|
|
10
10
|
readonly subject: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
|
|
@@ -19,7 +19,7 @@ export declare const projectAnnotationSnapshotSchema: v.StrictObjectSchema<{
|
|
|
19
19
|
readonly updated_at: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.SafeIntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
|
|
20
20
|
}, undefined>, undefined>, v.MaxLengthAction<{
|
|
21
21
|
ref: string;
|
|
22
|
-
type: "decision" | "fact" | "
|
|
22
|
+
type: "decision" | "fact" | "promise" | "question" | "statement";
|
|
23
23
|
statement: string;
|
|
24
24
|
status: "accepted" | "proposed" | "rejected" | "reversed" | "superseded";
|
|
25
25
|
subject: string | null;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { type FACT_STATUSES, FACT_TYPES, MEMORY_SCOPES } from "../../domain/knowledge.js";
|
|
2
|
-
import { type PAYMENT_DIRECTIONS, type PAYMENT_STATES, type Payment } from "../../meetings/index.js";
|
|
3
2
|
import { type Decider } from "../decisions.js";
|
|
4
3
|
import { type Actor, type ProposedByFilter } from "./actors.js";
|
|
5
4
|
import { type EvidenceSource } from "./evidence-sources.js";
|
|
@@ -35,8 +34,6 @@ export interface Fact {
|
|
|
35
34
|
scope: (typeof MEMORY_SCOPES)[number] | null;
|
|
36
35
|
source: string;
|
|
37
36
|
evidence: string[];
|
|
38
|
-
/** Set on a `payment` fact only. */
|
|
39
|
-
payment: Payment | null;
|
|
40
37
|
createdAt: string;
|
|
41
38
|
updatedAt: string;
|
|
42
39
|
}
|
|
@@ -55,14 +52,6 @@ export interface FactInput {
|
|
|
55
52
|
supersedes?: string;
|
|
56
53
|
/** The memory an agent drew it from: a `created-from` link. */
|
|
57
54
|
createdFrom?: string;
|
|
58
|
-
/** Required on a `payment` fact, refused on any other; checked as `validatePayment` does. */
|
|
59
|
-
payment?: Payment;
|
|
60
|
-
}
|
|
61
|
-
/** `open` is an expected payment that still holds: not superseded, rejected or cancelled. */
|
|
62
|
-
export interface PaymentFilter {
|
|
63
|
-
direction?: (typeof PAYMENT_DIRECTIONS)[number];
|
|
64
|
-
state?: (typeof PAYMENT_STATES)[number];
|
|
65
|
-
open?: boolean;
|
|
66
55
|
}
|
|
67
56
|
export interface FactsStore {
|
|
68
57
|
add(input: FactInput): Promise<Fact>;
|
|
@@ -73,8 +62,6 @@ export interface FactsStore {
|
|
|
73
62
|
subject?: string;
|
|
74
63
|
status?: Fact["status"];
|
|
75
64
|
proposedBy?: ProposedByFilter;
|
|
76
|
-
/** Narrows to payment facts by their fields. */
|
|
77
|
-
payment?: PaymentFilter;
|
|
78
65
|
/** `created` lists the newest written first; by default the newest by when it happened. */
|
|
79
66
|
order?: "occurred" | "created";
|
|
80
67
|
limit?: number;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { FACT_TYPES, MEMORY_SCOPES } from "../../domain/knowledge.js";
|
|
3
3
|
import { calendarMoment } from "../../domain/moment.js";
|
|
4
|
-
import { validatePayment } from "../../meetings/index.js";
|
|
5
4
|
import { assertMayDecide, assertMayWithdraw, deciderAuthor } from "../decisions.js";
|
|
6
5
|
import { proposedBySql, proposerOf } from "./actors.js";
|
|
7
6
|
import { evidenceSourcesOf } from "./evidence-sources.js";
|
|
@@ -36,7 +35,6 @@ export const factOperationsOver = (context) => {
|
|
|
36
35
|
scope: row.scope == null ? null : row.scope,
|
|
37
36
|
source: String(row.source),
|
|
38
37
|
evidence: evidenceOf(context, { type: "fact", id: Number(row.id) }),
|
|
39
|
-
payment: row.type === "payment" ? paymentOf(row.metadata) : null,
|
|
40
38
|
createdAt: iso(row.created_at),
|
|
41
39
|
updatedAt: iso(row.updated_at),
|
|
42
40
|
});
|
|
@@ -54,7 +52,6 @@ export const factOperationsOver = (context) => {
|
|
|
54
52
|
const decided = (row, by) => assertMayDecide({
|
|
55
53
|
id: `fact:${row.id}`,
|
|
56
54
|
proposedBy: proposerOf(database, row.author_type, row.author_id),
|
|
57
|
-
ownerOnly: row.type === "payment",
|
|
58
55
|
}, by, context.decisions);
|
|
59
56
|
/** Keeps who decided for the owner and why in `metadata.decision`; the decider's actor is in `confirmed_by`. */
|
|
60
57
|
const keepDecision = (id, by, reason) => {
|
|
@@ -83,6 +80,8 @@ export const factOperationsOver = (context) => {
|
|
|
83
80
|
};
|
|
84
81
|
return {
|
|
85
82
|
add: (input) => {
|
|
83
|
+
if (input.type === "payment")
|
|
84
|
+
throw new CliError("validation_error", "a payment is not a fact: record it with store.finance.addPayment, a payment: row, or an invoice: for one expected", { reason: "fact.payment-retired" });
|
|
86
85
|
const type = oneOf(input.type, FACT_TYPES, "a fact's type");
|
|
87
86
|
const statement = input.statement?.trim() ?? "";
|
|
88
87
|
if (!statement || statement.length > 2000)
|
|
@@ -91,7 +90,6 @@ export const factOperationsOver = (context) => {
|
|
|
91
90
|
const occurredAt = input.occurredAt === undefined ? null : calendarMoment(input.occurredAt, "occurredAt");
|
|
92
91
|
if (occurredAt !== null && !Number.isFinite(occurredAt))
|
|
93
92
|
throw new CliError("validation_error", "occurredAt takes an ISO timestamp");
|
|
94
|
-
const payment = paymentInput(type, input);
|
|
95
93
|
const id = write(database, () => {
|
|
96
94
|
const project = input.project === undefined ? undefined : requiredThing(database, input.project, "the project");
|
|
97
95
|
if (project && project.type !== "project")
|
|
@@ -100,10 +98,6 @@ export const factOperationsOver = (context) => {
|
|
|
100
98
|
const replaced = input.supersedes === undefined ? undefined : rowOf(input.supersedes);
|
|
101
99
|
if (replaced && replaced.status !== "proposed" && replaced.status !== "accepted")
|
|
102
100
|
throw new CliError("validation_error", `${input.supersedes} is ${String(replaced.status)}; only a proposed or accepted fact can be corrected`);
|
|
103
|
-
if (replaced && (replaced.type === "payment") !== (type === "payment"))
|
|
104
|
-
throw new CliError("validation_error", "a payment fact corrects a payment fact, and only one");
|
|
105
|
-
if (payment?.counterparty)
|
|
106
|
-
requiredThing(database, payment.counterparty, "the counterparty");
|
|
107
101
|
const owner = input.by === "owner";
|
|
108
102
|
const { actor, model } = authorActor(context, input.by);
|
|
109
103
|
const at = now();
|
|
@@ -111,7 +105,7 @@ export const factOperationsOver = (context) => {
|
|
|
111
105
|
.prepare("INSERT INTO facts (id, type, statement, status, project_id, subject_type, subject_id, occurred_at, supersedes_id, " +
|
|
112
106
|
"author_type, author_id, model, scope, source, metadata, created_at, updated_at) " +
|
|
113
107
|
"VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
|
|
114
|
-
.get(nextId(database, "facts"), type, statement, project?.id ?? null, subject?.type ?? null, subject?.id ?? null, occurredAt, replaced === undefined ? null : Number(replaced.id), actor.type, actor.id, model, scope, owner ? "owner" : "agent",
|
|
108
|
+
.get(nextId(database, "facts"), type, statement, project?.id ?? null, subject?.type ?? null, subject?.id ?? null, occurredAt, replaced === undefined ? null : Number(replaced.id), actor.type, actor.id, model, scope, owner ? "owner" : "agent", null, at, at)?.id);
|
|
115
109
|
linkEvidence(context, { type: "fact", id: made }, input.evidence ?? [], owner ? "owner" : "agent");
|
|
116
110
|
if (input.createdFrom !== undefined) {
|
|
117
111
|
const from = requiredThing(database, input.createdFrom, "the source");
|
|
@@ -132,12 +126,11 @@ export const factOperationsOver = (context) => {
|
|
|
132
126
|
const subject = options.subject === undefined ? undefined : requiredThing(database, options.subject);
|
|
133
127
|
const by = proposedBySql("f.author_type", "f.author_id", options.proposedBy);
|
|
134
128
|
const order = options.order === "created" ? "f.created_at" : "coalesce(f.occurred_at, f.created_at)";
|
|
135
|
-
const paid = paymentSql(options.payment);
|
|
136
129
|
return database
|
|
137
130
|
.prepare(`${NAMED} WHERE f.deleted_at IS NULL AND (? IS NULL OR f.type = ?) AND (? IS NULL OR f.project_id = ?) ` +
|
|
138
131
|
"AND (? IS NULL OR (f.subject_type = ? AND f.subject_id = ?)) AND (? IS NULL OR f.status = ?) " +
|
|
139
|
-
`AND ${by.sql}
|
|
140
|
-
.all(options.type ?? null, options.type ?? null, project?.id ?? null, project?.id ?? null, subject?.type ?? null, subject?.type ?? null, subject?.id ?? null, options.status ?? null, options.status ?? null, ...by.params,
|
|
132
|
+
`AND ${by.sql} ORDER BY ${order} DESC, f.id DESC LIMIT ?`)
|
|
133
|
+
.all(options.type ?? null, options.type ?? null, project?.id ?? null, project?.id ?? null, subject?.type ?? null, subject?.type ?? null, subject?.id ?? null, options.status ?? null, options.status ?? null, ...by.params, Math.min(Math.max(options.limit ?? 100, 1), 500))
|
|
141
134
|
.map(factOf);
|
|
142
135
|
},
|
|
143
136
|
accept: (reference, by, reason) => {
|
|
@@ -195,52 +188,6 @@ export const factOperationsOver = (context) => {
|
|
|
195
188
|
sources: (reference) => evidenceSourcesOf(database, { type: "fact", id: Number(rowOf(reference).id) }),
|
|
196
189
|
};
|
|
197
190
|
};
|
|
198
|
-
const paymentInput = (type, input) => {
|
|
199
|
-
if (type !== "payment") {
|
|
200
|
-
if (input.payment !== undefined)
|
|
201
|
-
throw new CliError("validation_error", "only a payment fact takes a payment");
|
|
202
|
-
return undefined;
|
|
203
|
-
}
|
|
204
|
-
if (input.payment === undefined)
|
|
205
|
-
throw new CliError("validation_error", "a payment fact needs its payment: amount, currency, direction and state");
|
|
206
|
-
const evidence = input.evidence?.length ?? 0;
|
|
207
|
-
if (evidence < 1 || evidence > 8)
|
|
208
|
-
throw new CliError("validation_error", "a payment fact cites 1–8 references: the ask, the transfer or a sheet row");
|
|
209
|
-
return validatePayment(input.payment);
|
|
210
|
-
};
|
|
211
|
-
const paymentOf = (metadata) => {
|
|
212
|
-
if (metadata == null)
|
|
213
|
-
return null;
|
|
214
|
-
try {
|
|
215
|
-
const { payment } = JSON.parse(String(metadata));
|
|
216
|
-
return payment ?? null;
|
|
217
|
-
}
|
|
218
|
-
catch {
|
|
219
|
-
return null;
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
|
-
const paymentSql = (filter) => {
|
|
223
|
-
if (filter === undefined)
|
|
224
|
-
return { sql: "", params: [] };
|
|
225
|
-
const parts = ["f.type = 'payment'"];
|
|
226
|
-
const params = [];
|
|
227
|
-
const state = filter.open ? "expected" : filter.state;
|
|
228
|
-
if (filter.open && filter.state !== undefined && filter.state !== "expected")
|
|
229
|
-
parts.push("0");
|
|
230
|
-
if (filter.direction !== undefined) {
|
|
231
|
-
parts.push("json_extract(f.metadata, '$.payment.direction') = ?");
|
|
232
|
-
params.push(filter.direction);
|
|
233
|
-
}
|
|
234
|
-
if (state !== undefined) {
|
|
235
|
-
parts.push("json_extract(f.metadata, '$.payment.state') = ?");
|
|
236
|
-
params.push(state);
|
|
237
|
-
}
|
|
238
|
-
if (filter.open)
|
|
239
|
-
parts.push("f.status IN ('proposed', 'accepted')");
|
|
240
|
-
if (filter.open === false)
|
|
241
|
-
parts.push("NOT (json_extract(f.metadata, '$.payment.state') = 'expected' AND f.status IN ('proposed', 'accepted'))");
|
|
242
|
-
return { sql: ` AND ${parts.join(" AND ")}`, params };
|
|
243
|
-
};
|
|
244
191
|
const migratedFrom = (metadata) => {
|
|
245
192
|
try {
|
|
246
193
|
const { memory } = JSON.parse(metadata);
|
|
@@ -28,6 +28,8 @@ interface Reviewed {
|
|
|
28
28
|
source: string;
|
|
29
29
|
/** Why an agent or an import proposed it. */
|
|
30
30
|
reason: string | null;
|
|
31
|
+
/** What it was read from: the messages, documents or notes an agent or the owner cited. */
|
|
32
|
+
evidence: string[];
|
|
31
33
|
project: string | null;
|
|
32
34
|
projectName: string | null;
|
|
33
35
|
createdAt: string;
|
|
@@ -97,6 +99,8 @@ interface Written {
|
|
|
97
99
|
source?: string;
|
|
98
100
|
/** Why a bot proposes it, kept for the reviewer. */
|
|
99
101
|
reason?: string;
|
|
102
|
+
/** References it was read from, linked as `evidence`. */
|
|
103
|
+
evidence?: string[];
|
|
100
104
|
}
|
|
101
105
|
export interface InvoiceLineInput {
|
|
102
106
|
description: string;
|
|
@@ -3,7 +3,7 @@ import { FINANCE_REVIEWS, INVOICE_DIRECTIONS, INVOICE_STATUSES, invoiceTitle, MO
|
|
|
3
3
|
import { currencyOf, formatDecimal, lineUnits, minorUnits, moneyLabel, moneyOf, quantityOf, scaleOf, } from "../../domain/money.js";
|
|
4
4
|
import { assertMayDecide, botKey, deciderAuthor } from "../decisions.js";
|
|
5
5
|
import { proposedBySql, proposerOf } from "./actors.js";
|
|
6
|
-
import { authorActor, authorOf, projectId, projectOf, subjectOf } from "./memories.js";
|
|
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
9
|
const refuse = (message, reason, code = "validation_error") => {
|
|
@@ -30,6 +30,13 @@ const isoDate = (value, what) => {
|
|
|
30
30
|
return date;
|
|
31
31
|
};
|
|
32
32
|
const iso = (value) => new Date(Number(value)).toISOString();
|
|
33
|
+
const evidenceList = (value) => {
|
|
34
|
+
if (value === undefined)
|
|
35
|
+
return [];
|
|
36
|
+
if (!Array.isArray(value) || value.length > 8 || value.some((one) => typeof one !== "string" || !one.trim()))
|
|
37
|
+
return refuse("evidence is a list of at most 8 references", "finance.field");
|
|
38
|
+
return [...new Set(value.map((one) => one.trim()))];
|
|
39
|
+
};
|
|
33
40
|
const MAX_LINES = 500;
|
|
34
41
|
const reasonOf = (metadata) => {
|
|
35
42
|
if (metadata == null)
|
|
@@ -65,7 +72,7 @@ export const financeOperationsOver = (context) => {
|
|
|
65
72
|
return refuse(`${what} is a person: or organization: reference, not ${reference}`, "finance.party");
|
|
66
73
|
return thing;
|
|
67
74
|
};
|
|
68
|
-
const reviewedOf = (row) => ({
|
|
75
|
+
const reviewedOf = (row, type) => ({
|
|
69
76
|
review: row.review,
|
|
70
77
|
author: authorOf(row),
|
|
71
78
|
model: row.model == null ? null : String(row.model),
|
|
@@ -74,6 +81,7 @@ export const financeOperationsOver = (context) => {
|
|
|
74
81
|
: { type: row.confirmed_by_type, id: String(row.confirmed_by_id) },
|
|
75
82
|
source: String(row.source),
|
|
76
83
|
reason: reasonOf(row.metadata),
|
|
84
|
+
evidence: evidenceOf(context, { type, id: Number(row.id) }),
|
|
77
85
|
...projectOf(database, row),
|
|
78
86
|
createdAt: iso(row.created_at),
|
|
79
87
|
updatedAt: iso(row.updated_at),
|
|
@@ -109,7 +117,7 @@ export const financeOperationsOver = (context) => {
|
|
|
109
117
|
issueDate: row.issue_date == null ? null : String(row.issue_date),
|
|
110
118
|
dueDate: fields.dueDate,
|
|
111
119
|
status: fields.status,
|
|
112
|
-
...reviewedOf(row),
|
|
120
|
+
...reviewedOf(row, "invoice"),
|
|
113
121
|
};
|
|
114
122
|
};
|
|
115
123
|
const paymentOf = (row) => {
|
|
@@ -140,7 +148,7 @@ export const financeOperationsOver = (context) => {
|
|
|
140
148
|
reference: row.reference == null ? null : String(row.reference),
|
|
141
149
|
allocated: formatDecimal(allocated, scale),
|
|
142
150
|
unallocated: formatDecimal(amount - allocated, scale),
|
|
143
|
-
...reviewedOf(row),
|
|
151
|
+
...reviewedOf(row, "payment"),
|
|
144
152
|
};
|
|
145
153
|
};
|
|
146
154
|
const allocationsWhere = (column, id) => database
|
|
@@ -236,8 +244,13 @@ export const financeOperationsOver = (context) => {
|
|
|
236
244
|
confirmedId: owner ? actor.id : null,
|
|
237
245
|
metadata: reason === null ? null : JSON.stringify({ reason }),
|
|
238
246
|
project: input.project === undefined ? null : projectId(database, input.project),
|
|
247
|
+
evidence: evidenceList(input.evidence),
|
|
239
248
|
};
|
|
240
249
|
};
|
|
250
|
+
const keepEvidence = (type, id, by) => {
|
|
251
|
+
if (by.evidence.length)
|
|
252
|
+
linkEvidence(context, { type, id }, by.evidence, by.owner ? "owner" : "agent");
|
|
253
|
+
};
|
|
241
254
|
const ownerOnly = (by, what) => {
|
|
242
255
|
if (by !== "owner")
|
|
243
256
|
refuse(`only the owner ${what}`, "finance.owner-only", "permission_error");
|
|
@@ -361,6 +374,7 @@ export const financeOperationsOver = (context) => {
|
|
|
361
374
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
|
362
375
|
for (const [index, line] of lines.entries())
|
|
363
376
|
insert.run(made, index + 1, line.description, line.quantity, line.unitPrice, line.amount, at, at);
|
|
377
|
+
keepEvidence("invoice", made, by);
|
|
364
378
|
return made;
|
|
365
379
|
});
|
|
366
380
|
return invoiceDetail(`invoice:${id}`);
|
|
@@ -436,6 +450,7 @@ export const financeOperationsOver = (context) => {
|
|
|
436
450
|
"reference, project_id, review, author_type, author_id, model, confirmed_by_type, confirmed_by_id, source, " +
|
|
437
451
|
"metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *")
|
|
438
452
|
.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);
|
|
453
|
+
keepEvidence("payment", Number(made.id), by);
|
|
439
454
|
const touched = allocations.map(({ invoice, units }) => allocateRow(made, invoice, units, by.actor));
|
|
440
455
|
for (const invoice of touched)
|
|
441
456
|
settle(invoice);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
-
import { findingFingerprint, findingHash,
|
|
2
|
+
import { findingFingerprint, findingHash, } from "../../meetings/index.js";
|
|
3
3
|
import { factOperationsOver } from "./facts.js";
|
|
4
4
|
import { linkEvidence } from "./memories.js";
|
|
5
5
|
import { requiredThing } from "./notes.js";
|
|
6
|
+
import { paymentEntriesOver } from "./payment-entries.js";
|
|
6
7
|
import { taskRowOf } from "./things.js";
|
|
7
8
|
import { workTaskOperationsOver } from "./work-tasks.js";
|
|
8
9
|
const invalid = (message) => {
|
|
@@ -27,6 +28,7 @@ export const findingsCoreOver = (context, kind) => {
|
|
|
27
28
|
const { database } = context;
|
|
28
29
|
const facts = factOperationsOver(context);
|
|
29
30
|
const tasks = workTaskOperationsOver(context);
|
|
31
|
+
const recordPayment = paymentEntriesOver(context);
|
|
30
32
|
const cited = (input, cite) => {
|
|
31
33
|
const seen = new Map();
|
|
32
34
|
for (const finding of input.findings) {
|
|
@@ -81,19 +83,8 @@ export const findingsCoreOver = (context, kind) => {
|
|
|
81
83
|
});
|
|
82
84
|
return facts.acceptChecked(fact.ref, decidedBy).ref;
|
|
83
85
|
}
|
|
84
|
-
if (finding.kind === "payment")
|
|
85
|
-
|
|
86
|
-
type: "payment",
|
|
87
|
-
statement: finding.statement ?? paymentTitle(finding),
|
|
88
|
-
payment: finding.payment,
|
|
89
|
-
...(finding.project === undefined ? {} : { project: finding.project }),
|
|
90
|
-
...(finding.subject === undefined ? {} : { subject: finding.subject }),
|
|
91
|
-
...(finding.supersedes === undefined ? {} : { supersedes: finding.supersedes }),
|
|
92
|
-
evidence,
|
|
93
|
-
by: agent,
|
|
94
|
-
});
|
|
95
|
-
return facts.acceptChecked(fact.ref, decidedBy).ref;
|
|
96
|
-
}
|
|
86
|
+
if (finding.kind === "payment")
|
|
87
|
+
return recordPayment({ ...finding, evidence }, agent, { confirm: true, by: decidedBy });
|
|
97
88
|
if (finding.project === undefined)
|
|
98
89
|
return invalid("a task finding names the project the task goes in");
|
|
99
90
|
const task = tasks.create({
|
|
@@ -61,6 +61,7 @@ export const MANIFEST = [
|
|
|
61
61
|
{ name: "20261102050534_version-44-suggested-links-move", version: 44, minCompatible: 34 },
|
|
62
62
|
{ name: "20261102173102_version-45-finance", version: 45, minCompatible: 34 },
|
|
63
63
|
{ name: "20261102173113_version-45-finance-rules", version: 45, minCompatible: 34 },
|
|
64
|
+
{ name: "20261102185954_version-46-payment-facts", version: 46, minCompatible: 34 },
|
|
64
65
|
];
|
|
65
66
|
export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
|
|
66
67
|
const migrations = [];
|
|
@@ -924,5 +924,13 @@ export const GENERATED = [
|
|
|
924
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
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
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
|
+
]
|
|
927
935
|
}
|
|
928
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;
|
package/dist/store/store.d.ts
CHANGED
|
@@ -115,7 +115,6 @@ export interface MessageStore {
|
|
|
115
115
|
forAccount(key: AccountKey): AccountStore;
|
|
116
116
|
close(): Promise<void>;
|
|
117
117
|
}
|
|
118
|
-
export type { PaymentFilter } from "./sqlite/facts.js";
|
|
119
118
|
export type { ChatHistoryShape, StoredHistorySkip } from "./sqlite/history-skips.js";
|
|
120
119
|
export { BACKFILL_ON_OPEN, type StoreOptions } from "./sqlite/open-store.js";
|
|
121
120
|
export { fileFacts, readStoreTables, type TableKind } from "./sqlite/store-tables.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.558.0",
|
|
4
4
|
"description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Viacheslav Ptsarev",
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import type { PaymentFilter } from "../store/store.js";
|
|
2
|
-
/**
|
|
3
|
-
* The terms a facts list adds for payments. `state` is the payment's, not `status`, which stays the review status
|
|
4
|
-
* (proposed, accepted).
|
|
5
|
-
*/
|
|
6
|
-
export declare const PAYMENT_QUERY_FIELDS: import("@wirecat/cli-base/query").FieldSpec[];
|
|
7
|
-
/** The store's payment filter from `queryTerms`' answer; `undefined` when no payment term was given. */
|
|
8
|
-
export declare const paymentFilterOf: (terms: Map<string, string>) => PaymentFilter | undefined;
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { CliError } from "@wirecat/cli-base";
|
|
2
|
-
import { PAYMENT_DIRECTIONS, PAYMENT_STATES } from "../meetings/index.js";
|
|
3
|
-
import { termField } from "./list-query.js";
|
|
4
|
-
/**
|
|
5
|
-
* The terms a facts list adds for payments. `state` is the payment's, not `status`, which stays the review status
|
|
6
|
-
* (proposed, accepted).
|
|
7
|
-
*/
|
|
8
|
-
export const PAYMENT_QUERY_FIELDS = [
|
|
9
|
-
termField("open", "bool", "open:true"),
|
|
10
|
-
termField("direction", "enum", "direction:to_us", { values: PAYMENT_DIRECTIONS }),
|
|
11
|
-
termField("state", "enum", "state:paid", { values: PAYMENT_STATES }),
|
|
12
|
-
];
|
|
13
|
-
/** The store's payment filter from `queryTerms`' answer; `undefined` when no payment term was given. */
|
|
14
|
-
export const paymentFilterOf = (terms) => {
|
|
15
|
-
const open = terms.get("open");
|
|
16
|
-
const direction = terms.get("direction");
|
|
17
|
-
const state = terms.get("state");
|
|
18
|
-
if (open === undefined && direction === undefined && state === undefined)
|
|
19
|
-
return undefined;
|
|
20
|
-
if (open !== undefined && open !== "true" && open !== "false")
|
|
21
|
-
throw new CliError("validation_error", "open takes true or false");
|
|
22
|
-
return {
|
|
23
|
-
...(open === undefined ? {} : { open: open === "true" }),
|
|
24
|
-
...(direction === undefined ? {} : { direction }),
|
|
25
|
-
...(state === undefined ? {} : { state }),
|
|
26
|
-
};
|
|
27
|
-
};
|