@slothmoney/agent-cli 0.15.0 → 0.17.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 +26 -0
- package/README.md +67 -5
- package/dist/args.js +101 -0
- package/dist/cli.js +322 -21
- package/dist/contracts.js +195 -21
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.0 - 2026-08-25
|
|
4
|
+
|
|
5
|
+
- Run applied transaction-assignment batches through resumable server
|
|
6
|
+
operations while keeping the existing `assign --input ... --apply` command
|
|
7
|
+
and terminal `succeeded`/`failed` JSON output unchanged.
|
|
8
|
+
- Retry transient submission and status requests with a deterministic
|
|
9
|
+
request key, so re-running an interrupted command resumes the same operation.
|
|
10
|
+
- Strictly validate operation progress, expiry, counts, and ordered terminal
|
|
11
|
+
item receipts before printing an assignment result.
|
|
12
|
+
|
|
13
|
+
## 0.16.0 - 2026-08-21
|
|
14
|
+
|
|
15
|
+
- Add receipt image extraction plus read, preview-by-default attach, and remove
|
|
16
|
+
commands for reviewed receipt items.
|
|
17
|
+
- Keep receipt images and extraction drafts transient; only reviewed JSON is
|
|
18
|
+
sent to the confirmed evidence endpoint.
|
|
19
|
+
- Keep reviewed receipt rows simple: `id`, `label`, and a signed amount, with
|
|
20
|
+
discounts represented as negative rows.
|
|
21
|
+
- Match notification-rule writes to the Agent API by sending only the optional
|
|
22
|
+
email choice while keeping in-app delivery server-owned and always enabled.
|
|
23
|
+
- Accept computed renewal reminder dates and nullable rule timestamps in rule
|
|
24
|
+
responses, and accept a null extracted date when a PDF contains no usable
|
|
25
|
+
renewal date.
|
|
26
|
+
- Send contract PDFs with the required filename and content fields, and align
|
|
27
|
+
the local 6 MB limit with the API's encoded-content limit.
|
|
28
|
+
|
|
3
29
|
## 0.15.0 - 2026-08-21
|
|
4
30
|
|
|
5
31
|
- Add `rules` commands to list, read, preview, apply, and delete notification
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ sloth-agent --version
|
|
|
15
15
|
For a one-off pinned run:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
npm exec --yes --package=@slothmoney/agent-cli@0.
|
|
18
|
+
npm exec --yes --package=@slothmoney/agent-cli@0.17.0 -- sloth-agent --help
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
## Authenticate
|
|
@@ -114,6 +114,9 @@ sloth-agent line-items create --help
|
|
|
114
114
|
sloth-agent transactions --help
|
|
115
115
|
sloth-agent rules --help
|
|
116
116
|
sloth-agent assign --help
|
|
117
|
+
sloth-agent receipts --help
|
|
118
|
+
sloth-agent receipts extract --help
|
|
119
|
+
sloth-agent receipts attach --help
|
|
117
120
|
sloth-agent goals create --help
|
|
118
121
|
sloth-agent goals update --help
|
|
119
122
|
sloth-agent goals mark-spent --help
|
|
@@ -143,12 +146,14 @@ First, run `sloth-agent transactions` and copy the exact `transactionRef` into
|
|
|
143
146
|
"leadDays": 30
|
|
144
147
|
},
|
|
145
148
|
"delivery": {
|
|
146
|
-
"inApp": true,
|
|
147
149
|
"email": true
|
|
148
150
|
}
|
|
149
151
|
}
|
|
150
152
|
```
|
|
151
153
|
|
|
154
|
+
In-app notifications are always included. Set `delivery.email` to choose
|
|
155
|
+
whether Sloth also sends an email. `leadDays` accepts an integer from 1 to 365.
|
|
156
|
+
|
|
152
157
|
Preview the write locally, then apply the same validated file:
|
|
153
158
|
|
|
154
159
|
```bash
|
|
@@ -171,6 +176,47 @@ sloth-agent rules scan-contract --contract contract.pdf --apply
|
|
|
171
176
|
The PDF is discarded after extraction and is not stored. Scanning only returns
|
|
172
177
|
the date and confidence; use `rules set` to save the resulting reminder.
|
|
173
178
|
|
|
179
|
+
### Attach receipt items end to end
|
|
180
|
+
|
|
181
|
+
Receipt items are evidence attached to a booked transaction. They do not
|
|
182
|
+
change its category, sharing, partner balance, or budget treatment.
|
|
183
|
+
|
|
184
|
+
1. Copy the exact `transactionRef` from `sloth-agent transactions`, then
|
|
185
|
+
extract a transient draft from a JPEG, PNG, or WebP image up to 8 MB:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
sloth-agent receipts extract --image /path/to/receipt.jpg > receipt-draft.json
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The image and draft are not saved by Sloth. Review the JSON, remove the outer
|
|
192
|
+
`draft` key and `warnings`, and keep `schemaVersion`, `currency`, and the
|
|
193
|
+
reviewed `receiptItems` in `receipt.json`. Each item contains only `id`, `label`,
|
|
194
|
+
and a signed integer `amountPence`. Purchases and added charges are positive;
|
|
195
|
+
discounts are negative. Do not add tax already included in other prices as a
|
|
196
|
+
separate row because the signed rows should sum to the printed receipt total.
|
|
197
|
+
|
|
198
|
+
2. Preview the exact write without contacting the API:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
sloth-agent receipts attach \
|
|
202
|
+
--transaction-ref PASTE_THE_EXACT_TRANSACTION_REF_HERE \
|
|
203
|
+
--input receipt.json
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
3. Apply the reviewed JSON with a write-enabled token:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
sloth-agent receipts attach \
|
|
210
|
+
--transaction-ref PASTE_THE_EXACT_TRANSACTION_REF_HERE \
|
|
211
|
+
--input receipt.json \
|
|
212
|
+
--apply
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Use `sloth-agent receipts get --transaction-ref REF` to read the saved
|
|
216
|
+
revision. Pass `--expected-revision N` when replacing it, or use
|
|
217
|
+
`sloth-agent receipts remove --transaction-ref REF --revision N --apply` to
|
|
218
|
+
remove it.
|
|
219
|
+
|
|
174
220
|
### How transaction categorisation is represented
|
|
175
221
|
|
|
176
222
|
Personal and joint category assignments are separate. A personal assignment
|
|
@@ -256,6 +302,10 @@ assignment, set `"assignmentScope": "joint"` in the assignment payload and use
|
|
|
256
302
|
|
|
257
303
|
These are placeholders. Do not submit the example values.
|
|
258
304
|
|
|
305
|
+
Each `transactionRef` may appear only once in an assignment file. Split one
|
|
306
|
+
transaction across categories with `categorySplits` instead of adding the same
|
|
307
|
+
transaction twice.
|
|
308
|
+
|
|
259
309
|
4. Preview the assignment without writing:
|
|
260
310
|
|
|
261
311
|
```bash
|
|
@@ -275,7 +325,17 @@ sloth-agent assign --input assignments.json --apply
|
|
|
275
325
|
|
|
276
326
|
This step requires a token created with **Allow changes**.
|
|
277
327
|
|
|
278
|
-
|
|
328
|
+
The CLI submits a durable server operation and polls its authenticated status
|
|
329
|
+
until every item has finished. It then prints the same `succeeded` and `failed`
|
|
330
|
+
arrays as before, so existing agent workflows do not need to change. Inspect
|
|
331
|
+
every item in both arrays.
|
|
332
|
+
|
|
333
|
+
If the command is interrupted or a request times out, re-run the same command
|
|
334
|
+
with the same assignment input. The CLI derives the same request key
|
|
335
|
+
from the validated assignments, so the server resumes the existing operation
|
|
336
|
+
instead of applying the batch again. The server retains operation status and
|
|
337
|
+
item receipts for seven days. Changing the assignments creates a different
|
|
338
|
+
operation.
|
|
279
339
|
|
|
280
340
|
6. Check the result in the same assignment scope that you changed. Successful
|
|
281
341
|
assignments update the category and optional budget line item on the
|
|
@@ -719,8 +779,10 @@ Command results are JSON on stdout. Diagnostics are written to stderr.
|
|
|
719
779
|
| `2` | Invalid command, option, URL, date, auth input, goal input, or assignment input |
|
|
720
780
|
| `3` | No credential or native secure storage is unavailable |
|
|
721
781
|
|
|
722
|
-
Assignment writes
|
|
723
|
-
returns exit code `1`
|
|
782
|
+
Assignment writes run as durable, best-effort operations. The CLI waits for the
|
|
783
|
+
terminal result and returns exit code `1` when any item failed, while preserving
|
|
784
|
+
the complete `succeeded` and `failed` arrays on stdout. Re-running an interrupted
|
|
785
|
+
command with the same input resumes the same server operation.
|
|
724
786
|
|
|
725
787
|
## Development
|
|
726
788
|
|
package/dist/args.js
CHANGED
|
@@ -93,6 +93,93 @@ function parseGoalPriority(value) {
|
|
|
93
93
|
}
|
|
94
94
|
return priority;
|
|
95
95
|
}
|
|
96
|
+
function parsePositiveRevision(value, name) {
|
|
97
|
+
if (!/^[1-9]\d*$/.test(value)) {
|
|
98
|
+
throw new UsageError(`${name} must be a positive whole number`);
|
|
99
|
+
}
|
|
100
|
+
const revision = Number(value);
|
|
101
|
+
if (!Number.isSafeInteger(revision)) {
|
|
102
|
+
throw new UsageError(`${name} must be a positive whole number`);
|
|
103
|
+
}
|
|
104
|
+
return revision;
|
|
105
|
+
}
|
|
106
|
+
function parseReceipts(args, baseUrl) {
|
|
107
|
+
const subcommand = args.shift();
|
|
108
|
+
if (!subcommand || !['extract', 'get', 'attach', 'remove'].includes(subcommand)) {
|
|
109
|
+
throw new UsageError('receipts requires extract, get, attach, or remove');
|
|
110
|
+
}
|
|
111
|
+
let transactionRef;
|
|
112
|
+
let image;
|
|
113
|
+
let input;
|
|
114
|
+
let expectedRevision;
|
|
115
|
+
let revision;
|
|
116
|
+
let apply = false;
|
|
117
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
118
|
+
const argument = args[index];
|
|
119
|
+
const read = (name) => {
|
|
120
|
+
const value = readOptionValue(args, index, name);
|
|
121
|
+
index += 1;
|
|
122
|
+
return value;
|
|
123
|
+
};
|
|
124
|
+
if (argument === '--apply') {
|
|
125
|
+
if (apply)
|
|
126
|
+
throw new UsageError('--apply may only be provided once');
|
|
127
|
+
apply = true;
|
|
128
|
+
}
|
|
129
|
+
else if (argument === '--transaction-ref') {
|
|
130
|
+
transactionRef = setOnce(transactionRef, read('--transaction-ref'), '--transaction-ref');
|
|
131
|
+
}
|
|
132
|
+
else if (argument === '--image') {
|
|
133
|
+
image = setOnce(image, read('--image'), '--image');
|
|
134
|
+
}
|
|
135
|
+
else if (argument === '--input') {
|
|
136
|
+
input = setOnce(input, read('--input'), '--input');
|
|
137
|
+
}
|
|
138
|
+
else if (argument === '--expected-revision') {
|
|
139
|
+
expectedRevision = setOnce(expectedRevision, parsePositiveRevision(read('--expected-revision'), '--expected-revision'), '--expected-revision');
|
|
140
|
+
}
|
|
141
|
+
else if (argument === '--revision') {
|
|
142
|
+
revision = setOnce(revision, parsePositiveRevision(read('--revision'), '--revision'), '--revision');
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
throw new UsageError(`Unknown receipts ${subcommand} option: ${argument}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (subcommand === 'extract') {
|
|
149
|
+
if (!image)
|
|
150
|
+
throw new UsageError('receipts extract requires --image <file>');
|
|
151
|
+
if (transactionRef || input || expectedRevision || revision || apply) {
|
|
152
|
+
throw new UsageError('receipts extract accepts only --image');
|
|
153
|
+
}
|
|
154
|
+
return withBaseUrl({ command: 'receipts-extract', image }, baseUrl);
|
|
155
|
+
}
|
|
156
|
+
if (!transactionRef)
|
|
157
|
+
throw new UsageError(`receipts ${subcommand} requires --transaction-ref <ref>`);
|
|
158
|
+
if (subcommand === 'get') {
|
|
159
|
+
if (image || input || expectedRevision || revision || apply) {
|
|
160
|
+
throw new UsageError('receipts get accepts only --transaction-ref');
|
|
161
|
+
}
|
|
162
|
+
return withBaseUrl({ command: 'receipts-get', transactionRef }, baseUrl);
|
|
163
|
+
}
|
|
164
|
+
if (subcommand === 'attach') {
|
|
165
|
+
if (!input)
|
|
166
|
+
throw new UsageError('receipts attach requires --input <file>');
|
|
167
|
+
if (image || revision)
|
|
168
|
+
throw new UsageError('receipts attach received an unsupported option');
|
|
169
|
+
return withBaseUrl({
|
|
170
|
+
command: 'receipts-attach',
|
|
171
|
+
transactionRef,
|
|
172
|
+
input,
|
|
173
|
+
...(expectedRevision === undefined ? {} : { expectedRevision }),
|
|
174
|
+
apply,
|
|
175
|
+
}, baseUrl);
|
|
176
|
+
}
|
|
177
|
+
if (revision === undefined)
|
|
178
|
+
throw new UsageError('receipts remove requires --revision <number>');
|
|
179
|
+
if (image || input || expectedRevision)
|
|
180
|
+
throw new UsageError('receipts remove received an unsupported option');
|
|
181
|
+
return withBaseUrl({ command: 'receipts-remove', transactionRef, revision, apply }, baseUrl);
|
|
182
|
+
}
|
|
96
183
|
function parseGoalType(value) {
|
|
97
184
|
if (!isGoalType(value)) {
|
|
98
185
|
throw new UsageError('--type must be keep or spend');
|
|
@@ -860,6 +947,17 @@ function helpTopic(argv) {
|
|
|
860
947
|
return 'budget-move';
|
|
861
948
|
return 'budget';
|
|
862
949
|
}
|
|
950
|
+
if (command === 'receipts') {
|
|
951
|
+
if (subcommand === 'extract')
|
|
952
|
+
return 'receipts-extract';
|
|
953
|
+
if (subcommand === 'get')
|
|
954
|
+
return 'receipts-get';
|
|
955
|
+
if (subcommand === 'attach')
|
|
956
|
+
return 'receipts-attach';
|
|
957
|
+
if (subcommand === 'remove')
|
|
958
|
+
return 'receipts-remove';
|
|
959
|
+
return 'receipts';
|
|
960
|
+
}
|
|
863
961
|
if (command === 'accounts'
|
|
864
962
|
|| command === 'transactions'
|
|
865
963
|
|| command === 'assign'
|
|
@@ -909,6 +1007,9 @@ export function parseArgs(argv) {
|
|
|
909
1007
|
if (command === 'budget') {
|
|
910
1008
|
return parseBudget(args, baseUrl);
|
|
911
1009
|
}
|
|
1010
|
+
if (command === 'receipts') {
|
|
1011
|
+
return parseReceipts(args, baseUrl);
|
|
1012
|
+
}
|
|
912
1013
|
if (command === 'transactions') {
|
|
913
1014
|
return withBaseUrl({ command, filters: parseTransactions(args) }, baseUrl);
|
|
914
1015
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
3
|
+
import nodePath from 'node:path';
|
|
2
4
|
import { parseArgs, resolveBaseUrl, } from './args.js';
|
|
3
5
|
import { ICON_KEYS } from './category-metadata.js';
|
|
4
|
-
import { parseApiResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, validateNotificationRulePayload, } from './contracts.js';
|
|
6
|
+
import { parseApiResponse, parseAssignmentOperationResponse, toLegacyAssignmentResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, validateNotificationRulePayload, validateReceiptConfirmation, } from './contracts.js';
|
|
5
7
|
import { createSystemCredentialStore, secureStorageUnavailableError, } from './credential-store.js';
|
|
6
8
|
import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
|
|
7
|
-
export const CLI_VERSION = '0.
|
|
9
|
+
export const CLI_VERSION = '0.17.0';
|
|
8
10
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
11
|
+
const MAX_CONTRACT_PDF_BYTES = 6_000_000;
|
|
9
12
|
const API_ORIGIN_HELP_LINES = [
|
|
10
13
|
'',
|
|
11
14
|
'API origin:',
|
|
@@ -14,6 +17,8 @@ const API_ORIGIN_HELP_LINES = [
|
|
|
14
17
|
' Use an origin-only URL with no credentials, path, query, or fragment.',
|
|
15
18
|
' HTTPS is required except for localhost development.',
|
|
16
19
|
];
|
|
20
|
+
const ASSIGNMENT_REQUEST_ATTEMPTS = 3;
|
|
21
|
+
const ASSIGNMENT_RETRY_DELAY_MS = 500;
|
|
17
22
|
export function usageText() {
|
|
18
23
|
return [
|
|
19
24
|
'Sloth Agent CLI',
|
|
@@ -50,6 +55,10 @@ export function usageText() {
|
|
|
50
55
|
' sloth-agent rules set --transaction-ref REF --input rule.json [--apply]',
|
|
51
56
|
' sloth-agent rules delete --transaction-ref REF [--apply]',
|
|
52
57
|
' sloth-agent rules scan-contract --contract FILE.pdf [--apply]',
|
|
58
|
+
' sloth-agent receipts extract --image FILE [--base-url URL]',
|
|
59
|
+
' sloth-agent receipts get --transaction-ref REF [--base-url URL]',
|
|
60
|
+
' sloth-agent receipts attach --transaction-ref REF --input receipt.json [--expected-revision N] [--apply]',
|
|
61
|
+
' sloth-agent receipts remove --transaction-ref REF --revision N [--apply]',
|
|
53
62
|
' sloth-agent goals [list] [--base-url URL]',
|
|
54
63
|
' sloth-agent goals create --name NAME --target-amount AMOUNT',
|
|
55
64
|
' --type keep|spend [--target-month YYYY-MM] [--apply] [--base-url URL]',
|
|
@@ -648,13 +657,19 @@ export function assignHelpText() {
|
|
|
648
657
|
' the payload it would send. It does not contact Sloth Money, verify the',
|
|
649
658
|
' transactionRef or category values, or write anything.',
|
|
650
659
|
' A successful preview does not guarantee that applying it will succeed.',
|
|
651
|
-
' With --apply,
|
|
652
|
-
'
|
|
660
|
+
' With --apply, the CLI submits one durable server operation, then polls authenticated status',
|
|
661
|
+
' until every item finishes. Transient submission and status failures are retried.',
|
|
662
|
+
' Re-run the same command with the same assignment input after an interruption;',
|
|
663
|
+
' the CLI resumes the same operation instead of duplicating its work.',
|
|
664
|
+
' Operation status and item receipts remain available on the server for seven days.',
|
|
665
|
+
' Assignments are best-effort; any failed item makes the command exit with code 1',
|
|
666
|
+
' while the complete terminal result remains available on stdout.',
|
|
653
667
|
' Applying requires a write-enabled token created with Allow changes.',
|
|
654
668
|
'',
|
|
655
669
|
'Input:',
|
|
656
670
|
' The top-level object must contain an assignments array.',
|
|
657
671
|
' Each assignment requires transactionRef and at least one category operation or sharing object.',
|
|
672
|
+
' Each transactionRef may appear only once in the assignments array.',
|
|
658
673
|
' sharing.isShared is required. shareRatio is optional from 0 to 1 and is your share.',
|
|
659
674
|
' userExclusiveAmountPence and partnerExclusiveAmountPence are optional nonnegative integers.',
|
|
660
675
|
' Omitted split values use saved defaults for a first share and preserve an existing split.',
|
|
@@ -696,7 +711,7 @@ export function assignHelpText() {
|
|
|
696
711
|
'',
|
|
697
712
|
'Output:',
|
|
698
713
|
' Preview mode returns dryRun, endpoint, and the validated payload.',
|
|
699
|
-
' Apply mode returns succeeded and failed
|
|
714
|
+
' Apply mode waits for the durable operation and returns succeeded and failed arrays.',
|
|
700
715
|
' Successful assignments update the original transaction. See the result in',
|
|
701
716
|
' Sloth Money → Transactions or read the transaction again through the CLI.',
|
|
702
717
|
' Assignments do not create a separate list.',
|
|
@@ -980,6 +995,53 @@ export function rulesGetHelpText() {
|
|
|
980
995
|
...API_ORIGIN_HELP_LINES,
|
|
981
996
|
].join('\n');
|
|
982
997
|
}
|
|
998
|
+
export function receiptsHelpText() {
|
|
999
|
+
return [
|
|
1000
|
+
'Sloth Agent CLI — receipts',
|
|
1001
|
+
'',
|
|
1002
|
+
'Extract, review, read, attach, or remove receipt items for a booked transaction.',
|
|
1003
|
+
'',
|
|
1004
|
+
'Commands:',
|
|
1005
|
+
' sloth-agent receipts extract Extract a transient draft from an image',
|
|
1006
|
+
' sloth-agent receipts get Read saved receipt items',
|
|
1007
|
+
' sloth-agent receipts attach Preview or save reviewed JSON',
|
|
1008
|
+
' sloth-agent receipts remove Preview or remove saved receipt items',
|
|
1009
|
+
...API_ORIGIN_HELP_LINES,
|
|
1010
|
+
'',
|
|
1011
|
+
'Receipt items are evidence only. They do not change categories, sharing, or budgets.',
|
|
1012
|
+
].join('\n');
|
|
1013
|
+
}
|
|
1014
|
+
export function receiptsExtractHelpText() {
|
|
1015
|
+
return [
|
|
1016
|
+
'Sloth Agent CLI — receipts extract',
|
|
1017
|
+
'',
|
|
1018
|
+
'Usage:',
|
|
1019
|
+
' sloth-agent receipts extract --image FILE [--base-url URL]',
|
|
1020
|
+
'',
|
|
1021
|
+
'Required input:',
|
|
1022
|
+
' --image FILE JPEG, PNG, or WebP image up to 8 MB.',
|
|
1023
|
+
'',
|
|
1024
|
+
'This sends the image for one extraction request and does not save it or the draft.',
|
|
1025
|
+
'Review the returned JSON before using receipts attach.',
|
|
1026
|
+
...API_ORIGIN_HELP_LINES,
|
|
1027
|
+
'',
|
|
1028
|
+
'Output:',
|
|
1029
|
+
' JSON containing draft.currency, draft.receiptItems, and draft.warnings.',
|
|
1030
|
+
].join('\n');
|
|
1031
|
+
}
|
|
1032
|
+
export function receiptsGetHelpText() {
|
|
1033
|
+
return [
|
|
1034
|
+
'Sloth Agent CLI — receipts get',
|
|
1035
|
+
'',
|
|
1036
|
+
'Usage:',
|
|
1037
|
+
' sloth-agent receipts get --transaction-ref REF [--base-url URL]',
|
|
1038
|
+
'',
|
|
1039
|
+
' --transaction-ref REF Exact transactionRef from transactions output.',
|
|
1040
|
+
'',
|
|
1041
|
+
'This command is read-only. Output contains receipt or null.',
|
|
1042
|
+
...API_ORIGIN_HELP_LINES,
|
|
1043
|
+
].join('\n');
|
|
1044
|
+
}
|
|
983
1045
|
export function rulesSetHelpText() {
|
|
984
1046
|
return [
|
|
985
1047
|
'Sloth Agent CLI — rules set',
|
|
@@ -1005,13 +1067,13 @@ export function rulesSetHelpText() {
|
|
|
1005
1067
|
' "renewalDate": "2027-07-30",',
|
|
1006
1068
|
' "leadDays": 30',
|
|
1007
1069
|
' },',
|
|
1008
|
-
' "delivery": { "
|
|
1070
|
+
' "delivery": { "email": true }',
|
|
1009
1071
|
' }',
|
|
1010
1072
|
'',
|
|
1011
1073
|
' comparison accepts increase or any. baselinePence is a positive integer.',
|
|
1012
1074
|
' renewalDate is YYYY-MM-DD or null when its reminder is disabled.',
|
|
1013
|
-
' leadDays is an integer from
|
|
1014
|
-
'
|
|
1075
|
+
' leadDays is an integer from 1 to 365. At least one condition must be enabled.',
|
|
1076
|
+
' In-app notifications are always included. delivery.email adds email delivery.',
|
|
1015
1077
|
'',
|
|
1016
1078
|
'Write behavior:',
|
|
1017
1079
|
' Without --apply, Sloth validates the file and prints a local preview.',
|
|
@@ -1023,6 +1085,25 @@ export function rulesSetHelpText() {
|
|
|
1023
1085
|
...API_ORIGIN_HELP_LINES,
|
|
1024
1086
|
].join('\n');
|
|
1025
1087
|
}
|
|
1088
|
+
export function receiptsAttachHelpText() {
|
|
1089
|
+
return [
|
|
1090
|
+
'Sloth Agent CLI — receipts attach',
|
|
1091
|
+
'',
|
|
1092
|
+
'Usage:',
|
|
1093
|
+
' sloth-agent receipts attach --transaction-ref REF --input FILE [--expected-revision N] [--apply]',
|
|
1094
|
+
'',
|
|
1095
|
+
'Required inputs:',
|
|
1096
|
+
' --transaction-ref REF Exact transactionRef from transactions output.',
|
|
1097
|
+
' --input FILE Reviewed JSON with schemaVersion, currency, and receiptItems.',
|
|
1098
|
+
' Each item has id, label, and signed amountPence; use negative for discounts.',
|
|
1099
|
+
' --expected-revision N Required when replacing saved evidence; omit for a new receipt.',
|
|
1100
|
+
'',
|
|
1101
|
+
'Write behavior:',
|
|
1102
|
+
' Without --apply, prints the exact request and makes no API call.',
|
|
1103
|
+
' With --apply, saves only the reviewed JSON. Images and extraction drafts are not saved.',
|
|
1104
|
+
...API_ORIGIN_HELP_LINES,
|
|
1105
|
+
].join('\n');
|
|
1106
|
+
}
|
|
1026
1107
|
export function rulesDeleteHelpText() {
|
|
1027
1108
|
return [
|
|
1028
1109
|
'Sloth Agent CLI — rules delete',
|
|
@@ -1068,6 +1149,19 @@ export function rulesScanContractHelpText() {
|
|
|
1068
1149
|
...API_ORIGIN_HELP_LINES,
|
|
1069
1150
|
].join('\n');
|
|
1070
1151
|
}
|
|
1152
|
+
export function receiptsRemoveHelpText() {
|
|
1153
|
+
return [
|
|
1154
|
+
'Sloth Agent CLI — receipts remove',
|
|
1155
|
+
'',
|
|
1156
|
+
'Usage:',
|
|
1157
|
+
' sloth-agent receipts remove --transaction-ref REF --revision N [--apply]',
|
|
1158
|
+
'',
|
|
1159
|
+
' --revision N Current positive revision from receipts get.',
|
|
1160
|
+
'',
|
|
1161
|
+
'Without --apply, prints a preview. With --apply, removes saved receipt items.',
|
|
1162
|
+
...API_ORIGIN_HELP_LINES,
|
|
1163
|
+
].join('\n');
|
|
1164
|
+
}
|
|
1071
1165
|
export function commandHelpText(topic) {
|
|
1072
1166
|
const helpByTopic = {
|
|
1073
1167
|
auth: authHelpText,
|
|
@@ -1095,6 +1189,11 @@ export function commandHelpText(topic) {
|
|
|
1095
1189
|
'rules-set': rulesSetHelpText,
|
|
1096
1190
|
'rules-delete': rulesDeleteHelpText,
|
|
1097
1191
|
'rules-scan-contract': rulesScanContractHelpText,
|
|
1192
|
+
receipts: receiptsHelpText,
|
|
1193
|
+
'receipts-extract': receiptsExtractHelpText,
|
|
1194
|
+
'receipts-get': receiptsGetHelpText,
|
|
1195
|
+
'receipts-attach': receiptsAttachHelpText,
|
|
1196
|
+
'receipts-remove': receiptsRemoveHelpText,
|
|
1098
1197
|
goals: goalsHelpText,
|
|
1099
1198
|
'goals-list': goalsListHelpText,
|
|
1100
1199
|
'goals-create': goalsCreateHelpText,
|
|
@@ -1219,11 +1318,46 @@ function readContractPdf(filePath) {
|
|
|
1219
1318
|
const message = error instanceof Error ? error.message : String(error);
|
|
1220
1319
|
throw new UsageError(`Failed to read contract PDF: ${message}`);
|
|
1221
1320
|
}
|
|
1222
|
-
if (file.length === 0 || file.length >
|
|
1321
|
+
if (file.length === 0 || file.length > MAX_CONTRACT_PDF_BYTES || file.subarray(0, 4).toString() !== '%PDF') {
|
|
1223
1322
|
throw new UsageError('Contract must be a PDF no larger than 6 MB');
|
|
1224
1323
|
}
|
|
1225
1324
|
return file;
|
|
1226
1325
|
}
|
|
1326
|
+
function readReceiptFile(filePath) {
|
|
1327
|
+
try {
|
|
1328
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
1329
|
+
}
|
|
1330
|
+
catch (error) {
|
|
1331
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1332
|
+
throw new UsageError(`Failed to read receipt JSON: ${message}`);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
function readReceiptImage(filePath) {
|
|
1336
|
+
const extension = filePath.toLowerCase().split('.').pop();
|
|
1337
|
+
const mimeType = extension === 'jpg' || extension === 'jpeg'
|
|
1338
|
+
? 'image/jpeg'
|
|
1339
|
+
: extension === 'png'
|
|
1340
|
+
? 'image/png'
|
|
1341
|
+
: extension === 'webp'
|
|
1342
|
+
? 'image/webp'
|
|
1343
|
+
: null;
|
|
1344
|
+
if (!mimeType)
|
|
1345
|
+
throw new UsageError('Receipt image must be JPEG, PNG, or WebP');
|
|
1346
|
+
try {
|
|
1347
|
+
const body = fs.readFileSync(filePath);
|
|
1348
|
+
if (body.length === 0)
|
|
1349
|
+
throw new UsageError('Receipt image is empty');
|
|
1350
|
+
if (body.length > 8 * 1024 * 1024)
|
|
1351
|
+
throw new UsageError('Receipt image must be 8 MB or smaller');
|
|
1352
|
+
return { body, mimeType };
|
|
1353
|
+
}
|
|
1354
|
+
catch (error) {
|
|
1355
|
+
if (error instanceof UsageError)
|
|
1356
|
+
throw error;
|
|
1357
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1358
|
+
throw new UsageError(`Failed to read receipt image: ${message}`);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1227
1361
|
function buildTransactionsQuery(filters) {
|
|
1228
1362
|
const params = new URLSearchParams();
|
|
1229
1363
|
if (filters.uncategorized !== undefined) {
|
|
@@ -1282,6 +1416,99 @@ function requestHeaders(token) {
|
|
|
1282
1416
|
'User-Agent': `sloth-agent/${CLI_VERSION}`,
|
|
1283
1417
|
};
|
|
1284
1418
|
}
|
|
1419
|
+
function assignmentIdempotencyKey(payload) {
|
|
1420
|
+
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
|
1421
|
+
}
|
|
1422
|
+
function isRetryableAssignmentRequestError(error) {
|
|
1423
|
+
if (error instanceof ApiError) {
|
|
1424
|
+
return error.status !== undefined
|
|
1425
|
+
&& [408, 425, 429, 499, 500, 502, 503, 504].includes(error.status);
|
|
1426
|
+
}
|
|
1427
|
+
return error instanceof TypeError
|
|
1428
|
+
|| (error instanceof Error && error.name === 'AbortError');
|
|
1429
|
+
}
|
|
1430
|
+
async function withAssignmentRequestRecovery(request, sleep) {
|
|
1431
|
+
let lastError;
|
|
1432
|
+
for (let attempt = 1; attempt <= ASSIGNMENT_REQUEST_ATTEMPTS; attempt += 1) {
|
|
1433
|
+
try {
|
|
1434
|
+
return await request();
|
|
1435
|
+
}
|
|
1436
|
+
catch (error) {
|
|
1437
|
+
lastError = error;
|
|
1438
|
+
if (!isRetryableAssignmentRequestError(error) || attempt === ASSIGNMENT_REQUEST_ATTEMPTS) {
|
|
1439
|
+
throw error;
|
|
1440
|
+
}
|
|
1441
|
+
await sleep(ASSIGNMENT_RETRY_DELAY_MS * attempt);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
throw lastError;
|
|
1445
|
+
}
|
|
1446
|
+
async function parseAssignmentOperationHttpResponse(response, token, expectedStatus) {
|
|
1447
|
+
const data = await parseHttpResponse(response, token);
|
|
1448
|
+
if (response.status !== expectedStatus) {
|
|
1449
|
+
throw new ApiError(`Agent API returned status ${response.status}; expected ${expectedStatus}`, response.status);
|
|
1450
|
+
}
|
|
1451
|
+
return parseAssignmentOperationResponse(data);
|
|
1452
|
+
}
|
|
1453
|
+
function assertAssignmentOperationMatchesPayload(operation, payload, expectedOperationId) {
|
|
1454
|
+
if (operation.itemCount !== payload.assignments.length
|
|
1455
|
+
|| (expectedOperationId !== undefined && operation.operationId !== expectedOperationId)
|
|
1456
|
+
|| (operation.status === 'completed'
|
|
1457
|
+
&& operation.results?.some((result, index) => (result.transactionRef !== payload.assignments[index]?.transactionRef)))) {
|
|
1458
|
+
throw new ApiError('Assignment operation response did not match the submitted assignment order');
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
async function applyAssignments(fetchImplementation, sleep, baseUrl, token, payload) {
|
|
1462
|
+
const endpoint = `${baseUrl}/api/agent/v1/transaction-assignments`;
|
|
1463
|
+
const idempotencyKey = assignmentIdempotencyKey(payload);
|
|
1464
|
+
let operation;
|
|
1465
|
+
try {
|
|
1466
|
+
operation = await withAssignmentRequestRecovery(async () => {
|
|
1467
|
+
const response = await fetchImplementation(endpoint, {
|
|
1468
|
+
method: 'POST',
|
|
1469
|
+
headers: {
|
|
1470
|
+
...requestHeaders(token),
|
|
1471
|
+
'Content-Type': 'application/json',
|
|
1472
|
+
'Idempotency-Key': idempotencyKey,
|
|
1473
|
+
},
|
|
1474
|
+
body: JSON.stringify(payload),
|
|
1475
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1476
|
+
});
|
|
1477
|
+
return parseAssignmentOperationHttpResponse(response, token, 202);
|
|
1478
|
+
}, sleep);
|
|
1479
|
+
}
|
|
1480
|
+
catch (error) {
|
|
1481
|
+
if (!isRetryableAssignmentRequestError(error))
|
|
1482
|
+
throw error;
|
|
1483
|
+
throw new ApiError('Assignment submission could not be confirmed. '
|
|
1484
|
+
+ 'Re-run the same command with the same assignment input to resume it.');
|
|
1485
|
+
}
|
|
1486
|
+
assertAssignmentOperationMatchesPayload(operation, payload);
|
|
1487
|
+
const operationId = operation.operationId;
|
|
1488
|
+
while (operation.status !== 'completed') {
|
|
1489
|
+
await sleep(operation.pollAfterMs);
|
|
1490
|
+
const statusEndpoint = `${endpoint}/${encodeURIComponent(operationId)}`;
|
|
1491
|
+
try {
|
|
1492
|
+
const nextOperation = await withAssignmentRequestRecovery(async () => {
|
|
1493
|
+
const response = await fetchImplementation(statusEndpoint, {
|
|
1494
|
+
method: 'GET',
|
|
1495
|
+
headers: requestHeaders(token),
|
|
1496
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1497
|
+
});
|
|
1498
|
+
return parseAssignmentOperationHttpResponse(response, token, 200);
|
|
1499
|
+
}, sleep);
|
|
1500
|
+
assertAssignmentOperationMatchesPayload(nextOperation, payload, operationId);
|
|
1501
|
+
operation = nextOperation;
|
|
1502
|
+
}
|
|
1503
|
+
catch (error) {
|
|
1504
|
+
if (!isRetryableAssignmentRequestError(error))
|
|
1505
|
+
throw error;
|
|
1506
|
+
throw new ApiError('Assignment status could not be recovered. '
|
|
1507
|
+
+ 'Re-run the same command with the same assignment input to resume it.');
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
return toLegacyAssignmentResponse(operation);
|
|
1511
|
+
}
|
|
1285
1512
|
async function validateCredentialRemotely(fetchImplementation, origin, token) {
|
|
1286
1513
|
const response = await fetchImplementation(`${origin}/api/agent/v1/categories`, {
|
|
1287
1514
|
method: 'GET',
|
|
@@ -1317,6 +1544,9 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1317
1544
|
?? Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
1318
1545
|
const readSecret = options.readSecret ?? defaultReadSecret;
|
|
1319
1546
|
const readStdin = options.readStdin ?? defaultReadStdin;
|
|
1547
|
+
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => {
|
|
1548
|
+
setTimeout(resolve, milliseconds);
|
|
1549
|
+
}));
|
|
1320
1550
|
const writeStdout = options.writeStdout ?? ((value) => process.stdout.write(value));
|
|
1321
1551
|
const writeStderr = options.writeStderr ?? ((value) => process.stderr.write(value));
|
|
1322
1552
|
let token;
|
|
@@ -1457,6 +1687,34 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1457
1687
|
});
|
|
1458
1688
|
return 0;
|
|
1459
1689
|
}
|
|
1690
|
+
const receiptConfirmation = parsed.command === 'receipts-attach'
|
|
1691
|
+
? validateReceiptConfirmation(readReceiptFile(parsed.input))
|
|
1692
|
+
: undefined;
|
|
1693
|
+
if (parsed.command === 'receipts-attach' && !parsed.apply) {
|
|
1694
|
+
writeJson(writeStdout, {
|
|
1695
|
+
dryRun: true,
|
|
1696
|
+
endpoint: `${baseUrl}/api/agent/v1/receipts/confirmed`,
|
|
1697
|
+
method: 'PUT',
|
|
1698
|
+
payload: {
|
|
1699
|
+
transactionRef: parsed.transactionRef,
|
|
1700
|
+
expectedRevision: parsed.expectedRevision ?? null,
|
|
1701
|
+
receipt: receiptConfirmation,
|
|
1702
|
+
},
|
|
1703
|
+
});
|
|
1704
|
+
return 0;
|
|
1705
|
+
}
|
|
1706
|
+
if (parsed.command === 'receipts-remove' && !parsed.apply) {
|
|
1707
|
+
writeJson(writeStdout, {
|
|
1708
|
+
dryRun: true,
|
|
1709
|
+
endpoint: `${baseUrl}/api/agent/v1/receipts/confirmed`,
|
|
1710
|
+
method: 'DELETE',
|
|
1711
|
+
payload: {
|
|
1712
|
+
transactionRef: parsed.transactionRef,
|
|
1713
|
+
expectedRevision: parsed.revision,
|
|
1714
|
+
},
|
|
1715
|
+
});
|
|
1716
|
+
return 0;
|
|
1717
|
+
}
|
|
1460
1718
|
const notificationRulePayload = parsed.command === 'rules-set'
|
|
1461
1719
|
? validateNotificationRulePayload(readNotificationRuleFile(parsed.input))
|
|
1462
1720
|
: undefined;
|
|
@@ -1497,6 +1755,58 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1497
1755
|
const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
|
|
1498
1756
|
token = credential.token;
|
|
1499
1757
|
const headers = requestHeaders(token);
|
|
1758
|
+
if (parsed.command === 'receipts-extract') {
|
|
1759
|
+
const image = readReceiptImage(parsed.image);
|
|
1760
|
+
const response = await fetchImplementation(`${baseUrl}/api/agent/v1/receipts/extract`, {
|
|
1761
|
+
method: 'POST',
|
|
1762
|
+
headers: { ...headers, 'Content-Type': image.mimeType },
|
|
1763
|
+
body: image.body,
|
|
1764
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1765
|
+
});
|
|
1766
|
+
const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
|
|
1767
|
+
writeJson(writeStdout, data);
|
|
1768
|
+
return 0;
|
|
1769
|
+
}
|
|
1770
|
+
if (parsed.command === 'receipts-get') {
|
|
1771
|
+
const query = new URLSearchParams({ transactionRef: parsed.transactionRef });
|
|
1772
|
+
const response = await fetchImplementation(`${baseUrl}/api/agent/v1/receipts/confirmed?${query.toString()}`, {
|
|
1773
|
+
method: 'GET',
|
|
1774
|
+
headers,
|
|
1775
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1776
|
+
});
|
|
1777
|
+
const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
|
|
1778
|
+
writeJson(writeStdout, data);
|
|
1779
|
+
return 0;
|
|
1780
|
+
}
|
|
1781
|
+
if (parsed.command === 'receipts-attach') {
|
|
1782
|
+
const response = await fetchImplementation(`${baseUrl}/api/agent/v1/receipts/confirmed`, {
|
|
1783
|
+
method: 'PUT',
|
|
1784
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
1785
|
+
body: JSON.stringify({
|
|
1786
|
+
transactionRef: parsed.transactionRef,
|
|
1787
|
+
expectedRevision: parsed.expectedRevision ?? null,
|
|
1788
|
+
receipt: receiptConfirmation,
|
|
1789
|
+
}),
|
|
1790
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1791
|
+
});
|
|
1792
|
+
const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
|
|
1793
|
+
writeJson(writeStdout, data);
|
|
1794
|
+
return 0;
|
|
1795
|
+
}
|
|
1796
|
+
if (parsed.command === 'receipts-remove') {
|
|
1797
|
+
const response = await fetchImplementation(`${baseUrl}/api/agent/v1/receipts/confirmed`, {
|
|
1798
|
+
method: 'DELETE',
|
|
1799
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
1800
|
+
body: JSON.stringify({
|
|
1801
|
+
transactionRef: parsed.transactionRef,
|
|
1802
|
+
expectedRevision: parsed.revision,
|
|
1803
|
+
}),
|
|
1804
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1805
|
+
});
|
|
1806
|
+
const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
|
|
1807
|
+
writeJson(writeStdout, data);
|
|
1808
|
+
return 0;
|
|
1809
|
+
}
|
|
1500
1810
|
if (parsed.command === 'accounts-update') {
|
|
1501
1811
|
const endpoint = `${baseUrl}/api/agent/v1/accounts/${encodeURIComponent(parsed.accountRef)}`;
|
|
1502
1812
|
const response = await fetchImplementation(endpoint, {
|
|
@@ -1679,17 +1989,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1679
1989
|
}
|
|
1680
1990
|
if (parsed.command === 'assign') {
|
|
1681
1991
|
const payload = assignmentPayload;
|
|
1682
|
-
const
|
|
1683
|
-
const response = await fetchImplementation(endpoint, {
|
|
1684
|
-
method: 'POST',
|
|
1685
|
-
headers: {
|
|
1686
|
-
...headers,
|
|
1687
|
-
'Content-Type': 'application/json',
|
|
1688
|
-
},
|
|
1689
|
-
body: JSON.stringify(payload),
|
|
1690
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1691
|
-
});
|
|
1692
|
-
const data = parseApiResponse('assign', await parseHttpResponse(response, token));
|
|
1992
|
+
const data = await applyAssignments(fetchImplementation, sleep, baseUrl, token, payload);
|
|
1693
1993
|
writeJson(writeStdout, data);
|
|
1694
1994
|
return hasFailures(data) ? 1 : 0;
|
|
1695
1995
|
}
|
|
@@ -1730,8 +2030,9 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1730
2030
|
method: 'POST',
|
|
1731
2031
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
1732
2032
|
body: JSON.stringify({
|
|
1733
|
-
|
|
2033
|
+
filename: nodePath.basename(parsed.contract),
|
|
1734
2034
|
mimeType: 'application/pdf',
|
|
2035
|
+
contentBase64: contract.toString('base64'),
|
|
1735
2036
|
}),
|
|
1736
2037
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1737
2038
|
});
|
package/dist/contracts.js
CHANGED
|
@@ -35,7 +35,7 @@ export function validateNotificationRulePayload(value) {
|
|
|
35
35
|
const delivery = requireObject(payload.delivery, 'delivery');
|
|
36
36
|
rejectUnknownFields(amountChange, new Set(['enabled', 'comparison', 'baselinePence']), 'amountChange');
|
|
37
37
|
rejectUnknownFields(renewalReminder, new Set(['enabled', 'renewalDate', 'leadDays']), 'renewalReminder');
|
|
38
|
-
rejectUnknownFields(delivery, new Set(['
|
|
38
|
+
rejectUnknownFields(delivery, new Set(['email']), 'delivery');
|
|
39
39
|
if (typeof amountChange.enabled !== 'boolean')
|
|
40
40
|
throw new UsageError('amountChange.enabled must be true or false');
|
|
41
41
|
if (amountChange.comparison !== 'increase' && amountChange.comparison !== 'any') {
|
|
@@ -52,11 +52,11 @@ export function validateNotificationRulePayload(value) {
|
|
|
52
52
|
if (renewalReminder.enabled && renewalReminder.renewalDate === null) {
|
|
53
53
|
throw new UsageError('renewalReminder.renewalDate is required when enabled');
|
|
54
54
|
}
|
|
55
|
-
if (!Number.isSafeInteger(renewalReminder.leadDays) || Number(renewalReminder.leadDays) <
|
|
56
|
-
throw new UsageError('renewalReminder.leadDays must be an integer from
|
|
55
|
+
if (!Number.isSafeInteger(renewalReminder.leadDays) || Number(renewalReminder.leadDays) < 1 || Number(renewalReminder.leadDays) > 365) {
|
|
56
|
+
throw new UsageError('renewalReminder.leadDays must be an integer from 1 to 365');
|
|
57
57
|
}
|
|
58
|
-
if (
|
|
59
|
-
throw new UsageError('delivery must
|
|
58
|
+
if (typeof delivery.email !== 'boolean') {
|
|
59
|
+
throw new UsageError('delivery.email must be true or false');
|
|
60
60
|
}
|
|
61
61
|
if (!amountChange.enabled && !renewalReminder.enabled) {
|
|
62
62
|
throw new UsageError('At least one notification rule must be enabled');
|
|
@@ -72,17 +72,25 @@ export function validateNotificationRulePayload(value) {
|
|
|
72
72
|
renewalDate: renewalReminder.renewalDate,
|
|
73
73
|
leadDays: Number(renewalReminder.leadDays),
|
|
74
74
|
},
|
|
75
|
-
delivery: {
|
|
75
|
+
delivery: { email: delivery.email },
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
78
|
function isNotificationRule(value) {
|
|
79
79
|
if (!isObject(value))
|
|
80
80
|
return false;
|
|
81
|
+
const renewalReminder = value.renewalReminder;
|
|
82
|
+
const delivery = value.delivery;
|
|
83
|
+
if (!isObject(renewalReminder) || !isObject(delivery))
|
|
84
|
+
return false;
|
|
81
85
|
try {
|
|
82
86
|
validateNotificationRulePayload({
|
|
83
87
|
amountChange: value.amountChange,
|
|
84
|
-
renewalReminder:
|
|
85
|
-
|
|
88
|
+
renewalReminder: {
|
|
89
|
+
enabled: renewalReminder.enabled,
|
|
90
|
+
renewalDate: renewalReminder.renewalDate,
|
|
91
|
+
leadDays: renewalReminder.leadDays,
|
|
92
|
+
},
|
|
93
|
+
delivery: { email: delivery.email },
|
|
86
94
|
});
|
|
87
95
|
}
|
|
88
96
|
catch {
|
|
@@ -97,8 +105,12 @@ function isNotificationRule(value) {
|
|
|
97
105
|
&& typeof value.merchantName === 'string'
|
|
98
106
|
&& typeof value.currency === 'string'
|
|
99
107
|
&& Number.isSafeInteger(value.sourceAmountPence)
|
|
100
|
-
&&
|
|
101
|
-
&&
|
|
108
|
+
&& hasOnlyFields(renewalReminder, ['enabled', 'renewalDate', 'leadDays', 'remindOn'])
|
|
109
|
+
&& (renewalReminder.remindOn === null || isIsoDate(renewalReminder.remindOn))
|
|
110
|
+
&& hasOnlyFields(delivery, ['inApp', 'email'])
|
|
111
|
+
&& delivery.inApp === true
|
|
112
|
+
&& (value.createdAt === null || isIsoDateTime(value.createdAt))
|
|
113
|
+
&& (value.updatedAt === null || isIsoDateTime(value.updatedAt));
|
|
102
114
|
}
|
|
103
115
|
function isNotificationRuleResponse(value) {
|
|
104
116
|
return isObject(value)
|
|
@@ -120,7 +132,7 @@ function isNotificationRuleDeleteResponse(value) {
|
|
|
120
132
|
function isRenewalExtractionResponse(value) {
|
|
121
133
|
return isObject(value)
|
|
122
134
|
&& hasOnlyFields(value, ['renewalDate', 'confidence'])
|
|
123
|
-
&& isIsoDate(value.renewalDate)
|
|
135
|
+
&& (value.renewalDate === null || isIsoDate(value.renewalDate))
|
|
124
136
|
&& (value.confidence === 'high' || value.confidence === 'medium' || value.confidence === 'low');
|
|
125
137
|
}
|
|
126
138
|
function validateSplit(value, index, splitIndex) {
|
|
@@ -257,7 +269,15 @@ export function validateAssignmentPayload(value) {
|
|
|
257
269
|
if (payload.assignments.length < 1 || payload.assignments.length > 100) {
|
|
258
270
|
throw new UsageError('assignments must contain between 1 and 100 items');
|
|
259
271
|
}
|
|
260
|
-
|
|
272
|
+
const assignments = payload.assignments.map(validateAssignment);
|
|
273
|
+
const transactionRefs = new Set();
|
|
274
|
+
for (const assignment of assignments) {
|
|
275
|
+
if (transactionRefs.has(assignment.transactionRef)) {
|
|
276
|
+
throw new UsageError('Each assignments[].transactionRef must be unique');
|
|
277
|
+
}
|
|
278
|
+
transactionRefs.add(assignment.transactionRef);
|
|
279
|
+
}
|
|
280
|
+
return { assignments };
|
|
261
281
|
}
|
|
262
282
|
export function validateBudgetUpdatePayload(value) {
|
|
263
283
|
const payload = requireObject(value, 'budget update payload');
|
|
@@ -289,6 +309,36 @@ export function validateBudgetUpdatePayload(value) {
|
|
|
289
309
|
});
|
|
290
310
|
return { allocations };
|
|
291
311
|
}
|
|
312
|
+
export function validateReceiptConfirmation(value) {
|
|
313
|
+
const receipt = requireObject(value, 'receipt');
|
|
314
|
+
rejectUnknownFields(receipt, new Set(['schemaVersion', 'currency', 'receiptItems']), 'receipt');
|
|
315
|
+
if (receipt.schemaVersion !== 1)
|
|
316
|
+
throw new UsageError('receipt.schemaVersion must be 1');
|
|
317
|
+
if (typeof receipt.currency !== 'string' || !/^[A-Z]{3}$/.test(receipt.currency)) {
|
|
318
|
+
throw new UsageError('receipt.currency must be a three-letter uppercase code');
|
|
319
|
+
}
|
|
320
|
+
if (!Array.isArray(receipt.receiptItems) || receipt.receiptItems.length < 1 || receipt.receiptItems.length > 200) {
|
|
321
|
+
throw new UsageError('receipt.receiptItems must contain between 1 and 200 items');
|
|
322
|
+
}
|
|
323
|
+
const receiptItems = receipt.receiptItems.map((value, index) => {
|
|
324
|
+
const label = `receipt.receiptItems[${index}]`;
|
|
325
|
+
const item = requireObject(value, label);
|
|
326
|
+
rejectUnknownFields(item, new Set(['id', 'label', 'amountPence']), label);
|
|
327
|
+
const id = requireString(item.id, `${label}.id`);
|
|
328
|
+
const itemLabel = requireString(item.label, `${label}.label`);
|
|
329
|
+
if (id.length > 80 || itemLabel.length > 160)
|
|
330
|
+
throw new UsageError(`${label} is too long`);
|
|
331
|
+
if (!Number.isSafeInteger(item.amountPence)) {
|
|
332
|
+
throw new UsageError(`${label}.amountPence must be a signed safe integer`);
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
id,
|
|
336
|
+
label: itemLabel,
|
|
337
|
+
amountPence: Number(item.amountPence),
|
|
338
|
+
};
|
|
339
|
+
});
|
|
340
|
+
return { schemaVersion: 1, currency: receipt.currency, receiptItems };
|
|
341
|
+
}
|
|
292
342
|
function isLineItemMap(value) {
|
|
293
343
|
if (!isObject(value))
|
|
294
344
|
return false;
|
|
@@ -463,6 +513,73 @@ function isAssignmentResponse(value) {
|
|
|
463
513
|
&& typeof item.error === 'string'
|
|
464
514
|
&& (item.transactionRef === undefined || typeof item.transactionRef === 'string'))));
|
|
465
515
|
}
|
|
516
|
+
function isAssignmentOperationResult(value) {
|
|
517
|
+
if (!isObject(value) || typeof value.transactionRef !== 'string')
|
|
518
|
+
return false;
|
|
519
|
+
const { status, ...legacyResult } = value;
|
|
520
|
+
if (status === 'succeeded') {
|
|
521
|
+
return isAssignmentResponse({ succeeded: [legacyResult], failed: [] });
|
|
522
|
+
}
|
|
523
|
+
if (status === 'failed') {
|
|
524
|
+
return isAssignmentResponse({ succeeded: [], failed: [legacyResult] });
|
|
525
|
+
}
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
export function parseAssignmentOperationResponse(value) {
|
|
529
|
+
const validBase = isObject(value)
|
|
530
|
+
&& hasOnlyFields(value, [
|
|
531
|
+
'operationId', 'status', 'itemCount', 'completedCount', 'failedCount',
|
|
532
|
+
'expiresAt', 'pollAfterMs', 'results',
|
|
533
|
+
])
|
|
534
|
+
&& typeof value.operationId === 'string'
|
|
535
|
+
&& /^[a-f0-9]{64}$/.test(value.operationId)
|
|
536
|
+
&& (value.status === 'pending'
|
|
537
|
+
|| value.status === 'processing'
|
|
538
|
+
|| value.status === 'completed')
|
|
539
|
+
&& Number.isSafeInteger(value.itemCount)
|
|
540
|
+
&& Number(value.itemCount) >= 1
|
|
541
|
+
&& Number(value.itemCount) <= 100
|
|
542
|
+
&& isNonnegativeSafeInteger(value.completedCount)
|
|
543
|
+
&& Number(value.completedCount) <= Number(value.itemCount)
|
|
544
|
+
&& isNonnegativeSafeInteger(value.failedCount)
|
|
545
|
+
&& Number(value.failedCount) <= Number(value.completedCount)
|
|
546
|
+
&& isIsoDateTime(value.expiresAt)
|
|
547
|
+
&& isNonnegativeSafeInteger(value.pollAfterMs)
|
|
548
|
+
&& Number(value.pollAfterMs) >= 100
|
|
549
|
+
&& Number(value.pollAfterMs) <= 10_000;
|
|
550
|
+
if (!validBase) {
|
|
551
|
+
throw new ApiError('Invalid assignment operation response from the Agent API');
|
|
552
|
+
}
|
|
553
|
+
const isComplete = value.status === 'completed';
|
|
554
|
+
const resultsAreValid = isComplete
|
|
555
|
+
? (Number(value.completedCount) === Number(value.itemCount)
|
|
556
|
+
&& Array.isArray(value.results)
|
|
557
|
+
&& value.results.length === Number(value.itemCount)
|
|
558
|
+
&& value.results.every(isAssignmentOperationResult)
|
|
559
|
+
&& value.results.filter((result) => result.status === 'failed').length
|
|
560
|
+
=== Number(value.failedCount))
|
|
561
|
+
: value.results === undefined;
|
|
562
|
+
if (!resultsAreValid) {
|
|
563
|
+
throw new ApiError('Invalid assignment operation response from the Agent API');
|
|
564
|
+
}
|
|
565
|
+
return value;
|
|
566
|
+
}
|
|
567
|
+
export function toLegacyAssignmentResponse(value) {
|
|
568
|
+
const operation = parseAssignmentOperationResponse(value);
|
|
569
|
+
if (operation.status !== 'completed' || !operation.results) {
|
|
570
|
+
throw new ApiError('Assignment operation is not complete');
|
|
571
|
+
}
|
|
572
|
+
const succeeded = [];
|
|
573
|
+
const failed = [];
|
|
574
|
+
for (const result of operation.results) {
|
|
575
|
+
const { status, ...legacyResult } = result;
|
|
576
|
+
if (status === 'succeeded')
|
|
577
|
+
succeeded.push(legacyResult);
|
|
578
|
+
else
|
|
579
|
+
failed.push(legacyResult);
|
|
580
|
+
}
|
|
581
|
+
return { succeeded, failed };
|
|
582
|
+
}
|
|
466
583
|
function isHttpUrl(value) {
|
|
467
584
|
if (typeof value !== 'string')
|
|
468
585
|
return false;
|
|
@@ -819,6 +936,55 @@ function isGoalDeleteResponse(value) {
|
|
|
819
936
|
&& typeof value.deletedGoalId === 'string'
|
|
820
937
|
&& value.deletedGoalId.trim().length > 0);
|
|
821
938
|
}
|
|
939
|
+
function isReceiptConfirmation(value) {
|
|
940
|
+
try {
|
|
941
|
+
validateReceiptConfirmation(value);
|
|
942
|
+
return true;
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
return false;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
function isReceiptEvidence(value) {
|
|
949
|
+
if (!isObject(value))
|
|
950
|
+
return false;
|
|
951
|
+
const { revision, receiptTotalPence, confirmedAt, sourceSurface, ...confirmation } = value;
|
|
952
|
+
return (hasOnlyFields(value, [
|
|
953
|
+
'schemaVersion',
|
|
954
|
+
'currency',
|
|
955
|
+
'receiptItems',
|
|
956
|
+
'revision',
|
|
957
|
+
'receiptTotalPence',
|
|
958
|
+
'confirmedAt',
|
|
959
|
+
'sourceSurface',
|
|
960
|
+
])
|
|
961
|
+
&& isReceiptConfirmation(confirmation)
|
|
962
|
+
&& Number.isSafeInteger(revision)
|
|
963
|
+
&& Number(revision) > 0
|
|
964
|
+
&& Number.isSafeInteger(receiptTotalPence)
|
|
965
|
+
&& isIsoDateTime(confirmedAt)
|
|
966
|
+
&& (sourceSurface === 'web' || sourceSurface === 'agent_api'));
|
|
967
|
+
}
|
|
968
|
+
function isReceiptExtractResponse(value) {
|
|
969
|
+
if (!isObject(value) || !hasOnlyFields(value, ['draft']) || !isObject(value.draft))
|
|
970
|
+
return false;
|
|
971
|
+
const { warnings, ...confirmation } = value.draft;
|
|
972
|
+
return (isReceiptConfirmation(confirmation)
|
|
973
|
+
&& Array.isArray(warnings)
|
|
974
|
+
&& warnings.length <= 20
|
|
975
|
+
&& warnings.every((warning) => (warning === 'currency_unclear' || warning === 'total_unclear' || warning === 'item_unclear')));
|
|
976
|
+
}
|
|
977
|
+
function isReceiptLookupResponse(value) {
|
|
978
|
+
return isObject(value)
|
|
979
|
+
&& hasOnlyFields(value, ['receipt'])
|
|
980
|
+
&& (value.receipt === null || isReceiptEvidence(value.receipt));
|
|
981
|
+
}
|
|
982
|
+
function isReceiptMutationResponse(value) {
|
|
983
|
+
return isObject(value) && hasOnlyFields(value, ['receipt']) && isReceiptEvidence(value.receipt);
|
|
984
|
+
}
|
|
985
|
+
function isReceiptDeleteResponse(value) {
|
|
986
|
+
return isObject(value) && hasOnlyFields(value, ['deleted']) && value.deleted === true;
|
|
987
|
+
}
|
|
822
988
|
export function parseApiResponse(command, value) {
|
|
823
989
|
const valid = command === 'accounts'
|
|
824
990
|
? isAccountsResponse(value)
|
|
@@ -850,15 +1016,23 @@ export function parseApiResponse(command, value) {
|
|
|
850
1016
|
? isNotificationRuleDeleteResponse(value)
|
|
851
1017
|
: command === 'rules-scan-contract'
|
|
852
1018
|
? isRenewalExtractionResponse(value)
|
|
853
|
-
: command === '
|
|
854
|
-
?
|
|
855
|
-
: command === '
|
|
856
|
-
?
|
|
857
|
-
: command === '
|
|
858
|
-
?
|
|
859
|
-
: command === '
|
|
860
|
-
?
|
|
861
|
-
:
|
|
1019
|
+
: command === 'receipts-extract'
|
|
1020
|
+
? isReceiptExtractResponse(value)
|
|
1021
|
+
: command === 'receipts-get'
|
|
1022
|
+
? isReceiptLookupResponse(value)
|
|
1023
|
+
: command === 'receipts-attach'
|
|
1024
|
+
? isReceiptMutationResponse(value)
|
|
1025
|
+
: command === 'receipts-remove'
|
|
1026
|
+
? isReceiptDeleteResponse(value)
|
|
1027
|
+
: command === 'assign'
|
|
1028
|
+
? isAssignmentResponse(value)
|
|
1029
|
+
: command === 'ask-partner'
|
|
1030
|
+
? isPartnerResponse(value)
|
|
1031
|
+
: command === 'goals-list'
|
|
1032
|
+
? isGoalsResponse(value)
|
|
1033
|
+
: command === 'goals-delete'
|
|
1034
|
+
? isGoalDeleteResponse(value)
|
|
1035
|
+
: isGoalMutationResponse(value);
|
|
862
1036
|
if (!valid) {
|
|
863
1037
|
const label = command === 'assign' ? 'assignment' : command;
|
|
864
1038
|
throw new ApiError(`Invalid ${label} response from the Agent API`);
|