@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 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.15.0 -- sloth-agent --help
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
- Inspect every item in the returned `succeeded` and `failed` arrays.
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 are best-effort. A response containing any failed assignment
723
- returns exit code `1` while preserving the complete API response on stdout.
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.15.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, assignments are best-effort; any failed item makes the command',
652
- ' exit with code 1 while the complete result remains available on stdout.',
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 assignment arrays.',
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": { "inApp": true, "email": true }',
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 0 to 365. At least one condition must be enabled.',
1014
- ' delivery.inApp must be true; delivery.email adds email delivery.',
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 > 6 * 1024 * 1024 || file.subarray(0, 4).toString() !== '%PDF') {
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 endpoint = `${baseUrl}/api/agent/v1/transaction-assignments`;
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
- fileBase64: contract.toString('base64'),
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(['inApp', 'email']), 'delivery');
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) < 0 || Number(renewalReminder.leadDays) > 365) {
56
- throw new UsageError('renewalReminder.leadDays must be an integer from 0 to 365');
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 (delivery.inApp !== true || typeof delivery.email !== 'boolean') {
59
- throw new UsageError('delivery must include inApp: true and an email boolean');
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: { inApp: true, email: delivery.email },
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: value.renewalReminder,
85
- delivery: value.delivery,
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
- && typeof value.createdAt === 'string'
101
- && typeof value.updatedAt === 'string';
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
- return { assignments: payload.assignments.map(validateAssignment) };
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 === 'assign'
854
- ? isAssignmentResponse(value)
855
- : command === 'ask-partner'
856
- ? isPartnerResponse(value)
857
- : command === 'goals-list'
858
- ? isGoalsResponse(value)
859
- : command === 'goals-delete'
860
- ? isGoalDeleteResponse(value)
861
- : isGoalMutationResponse(value);
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`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slothmoney/agent-cli",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {