@slothmoney/agent-cli 0.14.1 → 0.16.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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0 - 2026-08-21
4
+
5
+ - Add receipt image extraction plus read, preview-by-default attach, and remove
6
+ commands for reviewed receipt items.
7
+ - Keep receipt images and extraction drafts transient; only reviewed JSON is
8
+ sent to the confirmed evidence endpoint.
9
+ - Keep reviewed receipt rows simple: `id`, `label`, and a signed amount, with
10
+ discounts represented as negative rows.
11
+ - Match notification-rule writes to the Agent API by sending only the optional
12
+ email choice while keeping in-app delivery server-owned and always enabled.
13
+ - Accept computed renewal reminder dates and nullable rule timestamps in rule
14
+ responses, and accept a null extracted date when a PDF contains no usable
15
+ renewal date.
16
+ - Send contract PDFs with the required filename and content fields, and align
17
+ the local 6 MB limit with the API's encoded-content limit.
18
+
19
+ ## 0.15.0 - 2026-08-21
20
+
21
+ - Add `rules` commands to list, read, preview, apply, and delete notification
22
+ rules anchored to existing transaction references.
23
+ - Add optional one-time PDF renewal-date extraction. Preview mode keeps the
24
+ file local; apply mode sends it for extraction and the service discards it
25
+ without storing the contract.
26
+ - Keep recurring predictions and transaction creation outside the Rules
27
+ contract.
28
+ - Remove the legacy transaction `--account-id` filter and stop exposing
29
+ internal account and connection locator fields in transaction responses.
30
+ Use the opaque `accountRef` returned by `sloth-agent accounts`.
31
+
3
32
  ## 0.14.1 - 2026-08-21
4
33
 
5
34
  - Make every parent help page list its nested commands, and give
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sloth Agent CLI
2
2
 
3
- Use your own agent to inspect accounts, investments, and budgets, manage goals, move assigned budget money, update planned amounts, and categorise transactions through the
3
+ Use your own agent to inspect accounts, investments, and budgets, manage goals, move assigned budget money, update planned amounts, categorise transactions, and configure payment notifications through the
4
4
  [Sloth Money Agent API](https://slothmoney.app/developers/).
5
5
 
6
6
  ## Install
@@ -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.14.1 -- sloth-agent --help
18
+ npm exec --yes --package=@slothmoney/agent-cli@0.16.0 -- sloth-agent --help
19
19
  ```
20
20
 
21
21
  ## Authenticate
@@ -112,7 +112,11 @@ sloth-agent categories create --help
112
112
  sloth-agent line-items --help
113
113
  sloth-agent line-items create --help
114
114
  sloth-agent transactions --help
115
+ sloth-agent rules --help
115
116
  sloth-agent assign --help
117
+ sloth-agent receipts --help
118
+ sloth-agent receipts extract --help
119
+ sloth-agent receipts attach --help
116
120
  sloth-agent goals create --help
117
121
  sloth-agent goals update --help
118
122
  sloth-agent goals mark-spent --help
@@ -120,6 +124,99 @@ sloth-agent goals restore --help
120
124
  sloth-agent ask-partner --help
121
125
  ```
122
126
 
127
+ ### Add a transaction notification rule
128
+
129
+ Rules watch future payments that match an existing transaction. They can alert
130
+ you when the amount changes or before a renewal date. They do not create
131
+ transactions or recurring predictions.
132
+
133
+ First, run `sloth-agent transactions` and copy the exact `transactionRef` into
134
+ `rule.json` alongside this rule definition:
135
+
136
+ ```json
137
+ {
138
+ "amountChange": {
139
+ "enabled": true,
140
+ "comparison": "increase",
141
+ "baselinePence": 3184
142
+ },
143
+ "renewalReminder": {
144
+ "enabled": true,
145
+ "renewalDate": "2027-07-30",
146
+ "leadDays": 30
147
+ },
148
+ "delivery": {
149
+ "email": true
150
+ }
151
+ }
152
+ ```
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
+
157
+ Preview the write locally, then apply the same validated file:
158
+
159
+ ```bash
160
+ sloth-agent rules set \
161
+ --transaction-ref PASTE_THE_EXACT_TRANSACTION_REF_HERE \
162
+ --input rule.json
163
+
164
+ sloth-agent rules set \
165
+ --transaction-ref PASTE_THE_EXACT_TRANSACTION_REF_HERE \
166
+ --input rule.json \
167
+ --apply
168
+ ```
169
+
170
+ To extract a renewal date first, scan a PDF no larger than 6 MB:
171
+
172
+ ```bash
173
+ sloth-agent rules scan-contract --contract contract.pdf --apply
174
+ ```
175
+
176
+ The PDF is discarded after extraction and is not stored. Scanning only returns
177
+ the date and confidence; use `rules set` to save the resulting reminder.
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
+
123
220
  ### How transaction categorisation is represented
124
221
 
125
222
  Personal and joint category assignments are separate. A personal assignment
@@ -482,9 +579,8 @@ sloth-agent transactions \
482
579
  --limit 50
483
580
  ```
484
581
 
485
- Copy the value from `sloth-agent accounts`. The older `--account-id` filter is
486
- still accepted for compatibility, but new workflows should use
487
- `--account-ref`. Do not provide both filters in one command.
582
+ Copy the value from `sloth-agent accounts`. Account references are the public
583
+ account identifier for transaction filtering.
488
584
 
489
585
  Account changes are previews unless `--apply` is present. Connected accounts
490
586
  support only goal-savings membership. Manual current accounts support their
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');
@@ -177,7 +264,6 @@ function parseTransactions(args) {
177
264
  '--end-date',
178
265
  '--q',
179
266
  '--account-ref',
180
- '--account-id',
181
267
  '--category-id',
182
268
  '--line-item-id',
183
269
  '--assignment-scope',
@@ -213,9 +299,6 @@ function parseTransactions(args) {
213
299
  else if (name === '--account-ref') {
214
300
  filters.accountRef = setOnce(filters.accountRef, parseAccountRef(value), name);
215
301
  }
216
- else if (name === '--account-id') {
217
- filters.accountId = setOnce(filters.accountId, value, name);
218
- }
219
302
  else if (name === '--category-id') {
220
303
  filters.categoryId = setOnce(filters.categoryId, value, name);
221
304
  }
@@ -237,9 +320,6 @@ function parseTransactions(args) {
237
320
  && filters.endDate < filters.startDate) {
238
321
  throw new UsageError('--end-date must not be before --start-date');
239
322
  }
240
- if (filters.accountRef !== undefined && filters.accountId !== undefined) {
241
- throw new UsageError('Use either --account-ref or --account-id, not both');
242
- }
243
323
  return filters;
244
324
  }
245
325
  function parseNamedOptions(args, commandLabel, allowed) {
@@ -752,6 +832,48 @@ function parseGoals(args, baseUrl) {
752
832
  }
753
833
  throw new UsageError(`Unknown goals command: ${subcommand}`);
754
834
  }
835
+ function parseRules(args, baseUrl) {
836
+ const subcommand = args.shift();
837
+ if (subcommand === undefined || subcommand === 'list') {
838
+ if (args.length > 0) {
839
+ throw new UsageError(`Unknown rules list option: ${args[0]}`);
840
+ }
841
+ return withBaseUrl({ command: 'rules-list' }, baseUrl);
842
+ }
843
+ if (subcommand === 'get' || subcommand === 'delete') {
844
+ const allowed = new Set(['--transaction-ref']);
845
+ const { values, apply } = parseNamedOptions(args, `rules ${subcommand}`, allowed);
846
+ const transactionRef = requiredOption(values, '--transaction-ref', `rules ${subcommand}`);
847
+ if (subcommand === 'get') {
848
+ if (apply)
849
+ throw new UsageError('rules get does not accept --apply');
850
+ return withBaseUrl({ command: 'rules-get', transactionRef }, baseUrl);
851
+ }
852
+ return withBaseUrl({
853
+ command: 'rules-delete',
854
+ transactionRef,
855
+ apply,
856
+ }, baseUrl);
857
+ }
858
+ if (subcommand === 'set') {
859
+ const { values, apply } = parseNamedOptions(args, 'rules set', new Set(['--transaction-ref', '--input']));
860
+ return withBaseUrl({
861
+ command: 'rules-set',
862
+ transactionRef: requiredOption(values, '--transaction-ref', 'rules set'),
863
+ input: requiredOption(values, '--input', 'rules set'),
864
+ apply,
865
+ }, baseUrl);
866
+ }
867
+ if (subcommand === 'scan-contract') {
868
+ const { values, apply } = parseNamedOptions(args, 'rules scan-contract', new Set(['--contract']));
869
+ return withBaseUrl({
870
+ command: 'rules-scan-contract',
871
+ contract: requiredOption(values, '--contract', 'rules scan-contract'),
872
+ apply,
873
+ }, baseUrl);
874
+ }
875
+ throw new UsageError(`Unknown rules command: ${subcommand}`);
876
+ }
755
877
  function helpTopic(argv) {
756
878
  const positionals = [];
757
879
  for (let index = 0; index < argv.length; index += 1) {
@@ -791,6 +913,17 @@ function helpTopic(argv) {
791
913
  return 'goals-delete';
792
914
  return 'goals';
793
915
  }
916
+ if (command === 'rules') {
917
+ if (subcommand === 'get')
918
+ return 'rules-get';
919
+ if (subcommand === 'set')
920
+ return 'rules-set';
921
+ if (subcommand === 'delete')
922
+ return 'rules-delete';
923
+ if (subcommand === 'scan-contract')
924
+ return 'rules-scan-contract';
925
+ return 'rules';
926
+ }
794
927
  if (command === 'categories') {
795
928
  if (subcommand === 'create')
796
929
  return 'categories-create';
@@ -814,6 +947,17 @@ function helpTopic(argv) {
814
947
  return 'budget-move';
815
948
  return 'budget';
816
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
+ }
817
961
  if (command === 'accounts'
818
962
  || command === 'transactions'
819
963
  || command === 'assign'
@@ -845,6 +989,9 @@ export function parseArgs(argv) {
845
989
  if (command === 'goals') {
846
990
  return parseGoals(args, baseUrl);
847
991
  }
992
+ if (command === 'rules') {
993
+ return parseRules(args, baseUrl);
994
+ }
848
995
  if (command === 'categories') {
849
996
  return parseCategories(args, baseUrl);
850
997
  }
@@ -860,6 +1007,9 @@ export function parseArgs(argv) {
860
1007
  if (command === 'budget') {
861
1008
  return parseBudget(args, baseUrl);
862
1009
  }
1010
+ if (command === 'receipts') {
1011
+ return parseReceipts(args, baseUrl);
1012
+ }
863
1013
  if (command === 'transactions') {
864
1014
  return withBaseUrl({ command, filters: parseTransactions(args) }, baseUrl);
865
1015
  }
package/dist/cli.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import fs from 'node:fs';
2
+ import nodePath from 'node:path';
2
3
  import { parseArgs, resolveBaseUrl, } from './args.js';
3
4
  import { ICON_KEYS } from './category-metadata.js';
4
- import { parseApiResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, } from './contracts.js';
5
+ import { parseApiResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, validateNotificationRulePayload, validateReceiptConfirmation, } from './contracts.js';
5
6
  import { createSystemCredentialStore, secureStorageUnavailableError, } from './credential-store.js';
6
7
  import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
7
- export const CLI_VERSION = '0.14.1';
8
+ export const CLI_VERSION = '0.16.0';
8
9
  const REQUEST_TIMEOUT_MS = 60_000;
10
+ const MAX_CONTRACT_PDF_BYTES = 6_000_000;
9
11
  const API_ORIGIN_HELP_LINES = [
10
12
  '',
11
13
  'API origin:',
@@ -42,9 +44,18 @@ export function usageText() {
42
44
  ' sloth-agent line-items rename --scope personal|joint --category-id ID --line-item-id ID --name NAME [--apply]',
43
45
  ' sloth-agent transactions [--uncategorized[=true|false]] [--shared[=true|false]] [--limit N]',
44
46
  ' [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--q TEXT]',
45
- ' [--account-ref REF] [--account-id ID] [--category-id ID] [--line-item-id ID]',
47
+ ' [--account-ref REF] [--category-id ID] [--line-item-id ID]',
46
48
  ' [--cursor CURSOR] [--base-url URL]',
47
49
  ' sloth-agent assign --input assignments.json [--apply] [--base-url URL]',
50
+ ' sloth-agent rules [list] [--base-url URL]',
51
+ ' sloth-agent rules get --transaction-ref REF [--base-url URL]',
52
+ ' sloth-agent rules set --transaction-ref REF --input rule.json [--apply]',
53
+ ' sloth-agent rules delete --transaction-ref REF [--apply]',
54
+ ' sloth-agent rules scan-contract --contract FILE.pdf [--apply]',
55
+ ' sloth-agent receipts extract --image FILE [--base-url URL]',
56
+ ' sloth-agent receipts get --transaction-ref REF [--base-url URL]',
57
+ ' sloth-agent receipts attach --transaction-ref REF --input receipt.json [--expected-revision N] [--apply]',
58
+ ' sloth-agent receipts remove --transaction-ref REF --revision N [--apply]',
48
59
  ' sloth-agent goals [list] [--base-url URL]',
49
60
  ' sloth-agent goals create --name NAME --target-amount AMOUNT',
50
61
  ' --type keep|spend [--target-month YYYY-MM] [--apply] [--base-url URL]',
@@ -589,7 +600,6 @@ export function transactionsHelpText() {
589
600
  ' --q TEXT Optional. Search transactions by text.',
590
601
  ' --account-ref REF Optional. Filter by the opaque accountRef from sloth-agent accounts.',
591
602
  ' Copy the exact sloth_account_v1_... value.',
592
- ' --account-id ID Optional legacy filter by provider or stored account ID.',
593
603
  ' --category-id ID Optional. Filter by category ID.',
594
604
  ' --line-item-id ID Optional. Filter primary or split assignments by line-item ID.',
595
605
  ' --assignment-scope SCOPE Optional. Filter assignments by personal or joint.',
@@ -601,7 +611,6 @@ export function transactionsHelpText() {
601
611
  '',
602
612
  'Constraints:',
603
613
  ' All filters are omitted by default.',
604
- ' Use either --account-ref or --account-id, not both.',
605
614
  ' --end-date must not be before --start-date.',
606
615
  ' The first transaction read each UTC day may refresh linked bank data.',
607
616
  ' Refresh remotely persists booked transactions and account balances.',
@@ -936,6 +945,214 @@ export function askPartnerHelpText() {
936
945
  ' JSON containing requestId, publicUrl, message, expiresAt, and status.',
937
946
  ].join('\n');
938
947
  }
948
+ export function rulesHelpText() {
949
+ return [
950
+ 'Sloth Agent CLI — rules',
951
+ '',
952
+ 'Manage notifications for future payments that match an existing transaction.',
953
+ 'Rules do not create transactions or recurring predictions.',
954
+ '',
955
+ 'Commands:',
956
+ ' rules list List every saved notification rule.',
957
+ ' rules get Read the rule for one transaction.',
958
+ ' rules set Preview or save a rule for one transaction.',
959
+ ' rules delete Preview or remove a rule.',
960
+ ' rules scan-contract Validate or scan a PDF for its renewal date.',
961
+ '',
962
+ 'Use the exact transactionRef returned by sloth-agent transactions.',
963
+ ...API_ORIGIN_HELP_LINES,
964
+ ].join('\n');
965
+ }
966
+ export function rulesGetHelpText() {
967
+ return [
968
+ 'Sloth Agent CLI — rules get',
969
+ '',
970
+ 'Read the saved notification rule for one existing transaction.',
971
+ '',
972
+ 'Usage:',
973
+ ' sloth-agent rules get --transaction-ref REF',
974
+ '',
975
+ 'Required:',
976
+ ' --transaction-ref REF The exact transactionRef from sloth-agent transactions.',
977
+ '',
978
+ 'Behavior:',
979
+ ' This command is read-only and requires an agent:read token.',
980
+ '',
981
+ 'Output:',
982
+ ' JSON containing the saved rule, or rule: null when no rule exists.',
983
+ '',
984
+ 'Example:',
985
+ ' sloth-agent rules get --transaction-ref PASTE_THE_EXACT_TRANSACTION_REF_HERE',
986
+ ...API_ORIGIN_HELP_LINES,
987
+ ].join('\n');
988
+ }
989
+ export function receiptsHelpText() {
990
+ return [
991
+ 'Sloth Agent CLI — receipts',
992
+ '',
993
+ 'Extract, review, read, attach, or remove receipt items for a booked transaction.',
994
+ '',
995
+ 'Commands:',
996
+ ' sloth-agent receipts extract Extract a transient draft from an image',
997
+ ' sloth-agent receipts get Read saved receipt items',
998
+ ' sloth-agent receipts attach Preview or save reviewed JSON',
999
+ ' sloth-agent receipts remove Preview or remove saved receipt items',
1000
+ ...API_ORIGIN_HELP_LINES,
1001
+ '',
1002
+ 'Receipt items are evidence only. They do not change categories, sharing, or budgets.',
1003
+ ].join('\n');
1004
+ }
1005
+ export function receiptsExtractHelpText() {
1006
+ return [
1007
+ 'Sloth Agent CLI — receipts extract',
1008
+ '',
1009
+ 'Usage:',
1010
+ ' sloth-agent receipts extract --image FILE [--base-url URL]',
1011
+ '',
1012
+ 'Required input:',
1013
+ ' --image FILE JPEG, PNG, or WebP image up to 8 MB.',
1014
+ '',
1015
+ 'This sends the image for one extraction request and does not save it or the draft.',
1016
+ 'Review the returned JSON before using receipts attach.',
1017
+ ...API_ORIGIN_HELP_LINES,
1018
+ '',
1019
+ 'Output:',
1020
+ ' JSON containing draft.currency, draft.receiptItems, and draft.warnings.',
1021
+ ].join('\n');
1022
+ }
1023
+ export function receiptsGetHelpText() {
1024
+ return [
1025
+ 'Sloth Agent CLI — receipts get',
1026
+ '',
1027
+ 'Usage:',
1028
+ ' sloth-agent receipts get --transaction-ref REF [--base-url URL]',
1029
+ '',
1030
+ ' --transaction-ref REF Exact transactionRef from transactions output.',
1031
+ '',
1032
+ 'This command is read-only. Output contains receipt or null.',
1033
+ ...API_ORIGIN_HELP_LINES,
1034
+ ].join('\n');
1035
+ }
1036
+ export function rulesSetHelpText() {
1037
+ return [
1038
+ 'Sloth Agent CLI — rules set',
1039
+ '',
1040
+ 'Preview or save notification conditions for one existing transaction.',
1041
+ '',
1042
+ 'Usage:',
1043
+ ' sloth-agent rules set --transaction-ref REF --input FILE [--apply]',
1044
+ '',
1045
+ 'Required:',
1046
+ ' --transaction-ref REF The exact transactionRef from sloth-agent transactions.',
1047
+ ' --input FILE A JSON rule file such as rule.json.',
1048
+ '',
1049
+ 'Input:',
1050
+ ' {',
1051
+ ' "amountChange": {',
1052
+ ' "enabled": true,',
1053
+ ' "comparison": "increase",',
1054
+ ' "baselinePence": 3184',
1055
+ ' },',
1056
+ ' "renewalReminder": {',
1057
+ ' "enabled": true,',
1058
+ ' "renewalDate": "2027-07-30",',
1059
+ ' "leadDays": 30',
1060
+ ' },',
1061
+ ' "delivery": { "email": true }',
1062
+ ' }',
1063
+ '',
1064
+ ' comparison accepts increase or any. baselinePence is a positive integer.',
1065
+ ' renewalDate is YYYY-MM-DD or null when its reminder is disabled.',
1066
+ ' leadDays is an integer from 1 to 365. At least one condition must be enabled.',
1067
+ ' In-app notifications are always included. delivery.email adds email delivery.',
1068
+ '',
1069
+ 'Write behavior:',
1070
+ ' Without --apply, Sloth validates the file and prints a local preview.',
1071
+ ' With --apply, Sloth replaces the saved rule using a write-enabled token.',
1072
+ '',
1073
+ 'Example:',
1074
+ ' sloth-agent rules set --transaction-ref PASTE_THE_EXACT_TRANSACTION_REF_HERE \\',
1075
+ ' --input rule.json --apply',
1076
+ ...API_ORIGIN_HELP_LINES,
1077
+ ].join('\n');
1078
+ }
1079
+ export function receiptsAttachHelpText() {
1080
+ return [
1081
+ 'Sloth Agent CLI — receipts attach',
1082
+ '',
1083
+ 'Usage:',
1084
+ ' sloth-agent receipts attach --transaction-ref REF --input FILE [--expected-revision N] [--apply]',
1085
+ '',
1086
+ 'Required inputs:',
1087
+ ' --transaction-ref REF Exact transactionRef from transactions output.',
1088
+ ' --input FILE Reviewed JSON with schemaVersion, currency, and receiptItems.',
1089
+ ' Each item has id, label, and signed amountPence; use negative for discounts.',
1090
+ ' --expected-revision N Required when replacing saved evidence; omit for a new receipt.',
1091
+ '',
1092
+ 'Write behavior:',
1093
+ ' Without --apply, prints the exact request and makes no API call.',
1094
+ ' With --apply, saves only the reviewed JSON. Images and extraction drafts are not saved.',
1095
+ ...API_ORIGIN_HELP_LINES,
1096
+ ].join('\n');
1097
+ }
1098
+ export function rulesDeleteHelpText() {
1099
+ return [
1100
+ 'Sloth Agent CLI — rules delete',
1101
+ '',
1102
+ 'Preview or remove the notification rule for one existing transaction.',
1103
+ '',
1104
+ 'Usage:',
1105
+ ' sloth-agent rules delete --transaction-ref REF [--apply]',
1106
+ '',
1107
+ 'Required:',
1108
+ ' --transaction-ref REF The exact transactionRef from sloth-agent transactions.',
1109
+ '',
1110
+ 'Write behavior:',
1111
+ ' Without --apply, Sloth prints a local deletion preview.',
1112
+ ' With --apply, Sloth removes the rule using a write-enabled token.',
1113
+ ...API_ORIGIN_HELP_LINES,
1114
+ ].join('\n');
1115
+ }
1116
+ export function rulesScanContractHelpText() {
1117
+ return [
1118
+ 'Sloth Agent CLI — rules scan-contract',
1119
+ '',
1120
+ 'Validate or scan a contract PDF for its renewal date.',
1121
+ '',
1122
+ 'Usage:',
1123
+ ' sloth-agent rules scan-contract --contract FILE.pdf [--apply]',
1124
+ '',
1125
+ 'Required:',
1126
+ ' --contract FILE.pdf A PDF no larger than 6 MB.',
1127
+ '',
1128
+ 'Behavior:',
1129
+ ' Without --apply, the PDF is validated locally and is not sent anywhere.',
1130
+ ' With --apply, the PDF is sent using a write-enabled token.',
1131
+ '',
1132
+ 'Contract privacy:',
1133
+ ' Sloth extracts a renewal date and then discards the file. It is not stored.',
1134
+ '',
1135
+ 'Output:',
1136
+ ' JSON containing renewalDate and confidence: high, medium, or low.',
1137
+ '',
1138
+ 'Example:',
1139
+ ' sloth-agent rules scan-contract --contract contract.pdf --apply',
1140
+ ...API_ORIGIN_HELP_LINES,
1141
+ ].join('\n');
1142
+ }
1143
+ export function receiptsRemoveHelpText() {
1144
+ return [
1145
+ 'Sloth Agent CLI — receipts remove',
1146
+ '',
1147
+ 'Usage:',
1148
+ ' sloth-agent receipts remove --transaction-ref REF --revision N [--apply]',
1149
+ '',
1150
+ ' --revision N Current positive revision from receipts get.',
1151
+ '',
1152
+ 'Without --apply, prints a preview. With --apply, removes saved receipt items.',
1153
+ ...API_ORIGIN_HELP_LINES,
1154
+ ].join('\n');
1155
+ }
939
1156
  export function commandHelpText(topic) {
940
1157
  const helpByTopic = {
941
1158
  auth: authHelpText,
@@ -958,6 +1175,16 @@ export function commandHelpText(topic) {
958
1175
  'line-items-rename': lineItemsRenameHelpText,
959
1176
  transactions: transactionsHelpText,
960
1177
  assign: assignHelpText,
1178
+ rules: rulesHelpText,
1179
+ 'rules-get': rulesGetHelpText,
1180
+ 'rules-set': rulesSetHelpText,
1181
+ 'rules-delete': rulesDeleteHelpText,
1182
+ 'rules-scan-contract': rulesScanContractHelpText,
1183
+ receipts: receiptsHelpText,
1184
+ 'receipts-extract': receiptsExtractHelpText,
1185
+ 'receipts-get': receiptsGetHelpText,
1186
+ 'receipts-attach': receiptsAttachHelpText,
1187
+ 'receipts-remove': receiptsRemoveHelpText,
961
1188
  goals: goalsHelpText,
962
1189
  'goals-list': goalsListHelpText,
963
1190
  'goals-create': goalsCreateHelpText,
@@ -1064,6 +1291,64 @@ function readBudgetFile(filePath) {
1064
1291
  throw new UsageError(`Failed to read budget JSON: ${message}`);
1065
1292
  }
1066
1293
  }
1294
+ function readNotificationRuleFile(filePath) {
1295
+ try {
1296
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
1297
+ }
1298
+ catch (error) {
1299
+ const message = error instanceof Error ? error.message : String(error);
1300
+ throw new UsageError(`Failed to read notification rule JSON: ${message}`);
1301
+ }
1302
+ }
1303
+ function readContractPdf(filePath) {
1304
+ let file;
1305
+ try {
1306
+ file = fs.readFileSync(filePath);
1307
+ }
1308
+ catch (error) {
1309
+ const message = error instanceof Error ? error.message : String(error);
1310
+ throw new UsageError(`Failed to read contract PDF: ${message}`);
1311
+ }
1312
+ if (file.length === 0 || file.length > MAX_CONTRACT_PDF_BYTES || file.subarray(0, 4).toString() !== '%PDF') {
1313
+ throw new UsageError('Contract must be a PDF no larger than 6 MB');
1314
+ }
1315
+ return file;
1316
+ }
1317
+ function readReceiptFile(filePath) {
1318
+ try {
1319
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
1320
+ }
1321
+ catch (error) {
1322
+ const message = error instanceof Error ? error.message : String(error);
1323
+ throw new UsageError(`Failed to read receipt JSON: ${message}`);
1324
+ }
1325
+ }
1326
+ function readReceiptImage(filePath) {
1327
+ const extension = filePath.toLowerCase().split('.').pop();
1328
+ const mimeType = extension === 'jpg' || extension === 'jpeg'
1329
+ ? 'image/jpeg'
1330
+ : extension === 'png'
1331
+ ? 'image/png'
1332
+ : extension === 'webp'
1333
+ ? 'image/webp'
1334
+ : null;
1335
+ if (!mimeType)
1336
+ throw new UsageError('Receipt image must be JPEG, PNG, or WebP');
1337
+ try {
1338
+ const body = fs.readFileSync(filePath);
1339
+ if (body.length === 0)
1340
+ throw new UsageError('Receipt image is empty');
1341
+ if (body.length > 8 * 1024 * 1024)
1342
+ throw new UsageError('Receipt image must be 8 MB or smaller');
1343
+ return { body, mimeType };
1344
+ }
1345
+ catch (error) {
1346
+ if (error instanceof UsageError)
1347
+ throw error;
1348
+ const message = error instanceof Error ? error.message : String(error);
1349
+ throw new UsageError(`Failed to read receipt image: ${message}`);
1350
+ }
1351
+ }
1067
1352
  function buildTransactionsQuery(filters) {
1068
1353
  const params = new URLSearchParams();
1069
1354
  if (filters.uncategorized !== undefined) {
@@ -1081,8 +1366,6 @@ function buildTransactionsQuery(filters) {
1081
1366
  params.set('q', filters.q);
1082
1367
  if (filters.accountRef !== undefined)
1083
1368
  params.set('accountRef', filters.accountRef);
1084
- if (filters.accountId !== undefined)
1085
- params.set('accountId', filters.accountId);
1086
1369
  if (filters.categoryId !== undefined)
1087
1370
  params.set('categoryId', filters.categoryId);
1088
1371
  if (filters.lineItemId !== undefined)
@@ -1299,9 +1582,126 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1299
1582
  });
1300
1583
  return 0;
1301
1584
  }
1585
+ const receiptConfirmation = parsed.command === 'receipts-attach'
1586
+ ? validateReceiptConfirmation(readReceiptFile(parsed.input))
1587
+ : undefined;
1588
+ if (parsed.command === 'receipts-attach' && !parsed.apply) {
1589
+ writeJson(writeStdout, {
1590
+ dryRun: true,
1591
+ endpoint: `${baseUrl}/api/agent/v1/receipts/confirmed`,
1592
+ method: 'PUT',
1593
+ payload: {
1594
+ transactionRef: parsed.transactionRef,
1595
+ expectedRevision: parsed.expectedRevision ?? null,
1596
+ receipt: receiptConfirmation,
1597
+ },
1598
+ });
1599
+ return 0;
1600
+ }
1601
+ if (parsed.command === 'receipts-remove' && !parsed.apply) {
1602
+ writeJson(writeStdout, {
1603
+ dryRun: true,
1604
+ endpoint: `${baseUrl}/api/agent/v1/receipts/confirmed`,
1605
+ method: 'DELETE',
1606
+ payload: {
1607
+ transactionRef: parsed.transactionRef,
1608
+ expectedRevision: parsed.revision,
1609
+ },
1610
+ });
1611
+ return 0;
1612
+ }
1613
+ const notificationRulePayload = parsed.command === 'rules-set'
1614
+ ? validateNotificationRulePayload(readNotificationRuleFile(parsed.input))
1615
+ : undefined;
1616
+ const notificationRuleEndpoint = (parsed.command === 'rules-get'
1617
+ || parsed.command === 'rules-set'
1618
+ || parsed.command === 'rules-delete')
1619
+ ? `${baseUrl}/api/agent/v1/notification-rules/for-transaction?${new URLSearchParams({
1620
+ transactionRef: parsed.transactionRef,
1621
+ }).toString()}`
1622
+ : undefined;
1623
+ if (parsed.command === 'rules-set' && !parsed.apply) {
1624
+ writeJson(writeStdout, {
1625
+ dryRun: true,
1626
+ endpoint: notificationRuleEndpoint,
1627
+ method: 'PUT',
1628
+ payload: { transactionRef: parsed.transactionRef, ...notificationRulePayload },
1629
+ });
1630
+ return 0;
1631
+ }
1632
+ if (parsed.command === 'rules-delete' && !parsed.apply) {
1633
+ writeJson(writeStdout, {
1634
+ dryRun: true,
1635
+ endpoint: notificationRuleEndpoint,
1636
+ method: 'DELETE',
1637
+ });
1638
+ return 0;
1639
+ }
1640
+ if (parsed.command === 'rules-scan-contract' && !parsed.apply) {
1641
+ const contract = readContractPdf(parsed.contract);
1642
+ writeJson(writeStdout, {
1643
+ dryRun: true,
1644
+ endpoint: `${baseUrl}/api/agent/v1/notification-rules/extract-renewal`,
1645
+ method: 'POST',
1646
+ contract: { bytes: contract.length, mimeType: 'application/pdf' },
1647
+ });
1648
+ return 0;
1649
+ }
1302
1650
  const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
1303
1651
  token = credential.token;
1304
1652
  const headers = requestHeaders(token);
1653
+ if (parsed.command === 'receipts-extract') {
1654
+ const image = readReceiptImage(parsed.image);
1655
+ const response = await fetchImplementation(`${baseUrl}/api/agent/v1/receipts/extract`, {
1656
+ method: 'POST',
1657
+ headers: { ...headers, 'Content-Type': image.mimeType },
1658
+ body: image.body,
1659
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1660
+ });
1661
+ const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
1662
+ writeJson(writeStdout, data);
1663
+ return 0;
1664
+ }
1665
+ if (parsed.command === 'receipts-get') {
1666
+ const query = new URLSearchParams({ transactionRef: parsed.transactionRef });
1667
+ const response = await fetchImplementation(`${baseUrl}/api/agent/v1/receipts/confirmed?${query.toString()}`, {
1668
+ method: 'GET',
1669
+ headers,
1670
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1671
+ });
1672
+ const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
1673
+ writeJson(writeStdout, data);
1674
+ return 0;
1675
+ }
1676
+ if (parsed.command === 'receipts-attach') {
1677
+ const response = await fetchImplementation(`${baseUrl}/api/agent/v1/receipts/confirmed`, {
1678
+ method: 'PUT',
1679
+ headers: { ...headers, 'Content-Type': 'application/json' },
1680
+ body: JSON.stringify({
1681
+ transactionRef: parsed.transactionRef,
1682
+ expectedRevision: parsed.expectedRevision ?? null,
1683
+ receipt: receiptConfirmation,
1684
+ }),
1685
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1686
+ });
1687
+ const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
1688
+ writeJson(writeStdout, data);
1689
+ return 0;
1690
+ }
1691
+ if (parsed.command === 'receipts-remove') {
1692
+ const response = await fetchImplementation(`${baseUrl}/api/agent/v1/receipts/confirmed`, {
1693
+ method: 'DELETE',
1694
+ headers: { ...headers, 'Content-Type': 'application/json' },
1695
+ body: JSON.stringify({
1696
+ transactionRef: parsed.transactionRef,
1697
+ expectedRevision: parsed.revision,
1698
+ }),
1699
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1700
+ });
1701
+ const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
1702
+ writeJson(writeStdout, data);
1703
+ return 0;
1704
+ }
1305
1705
  if (parsed.command === 'accounts-update') {
1306
1706
  const endpoint = `${baseUrl}/api/agent/v1/accounts/${encodeURIComponent(parsed.accountRef)}`;
1307
1707
  const response = await fetchImplementation(endpoint, {
@@ -1498,6 +1898,52 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1498
1898
  writeJson(writeStdout, data);
1499
1899
  return hasFailures(data) ? 1 : 0;
1500
1900
  }
1901
+ if (parsed.command === 'rules-list') {
1902
+ const response = await fetchImplementation(`${baseUrl}/api/agent/v1/notification-rules`, {
1903
+ method: 'GET', headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1904
+ });
1905
+ writeJson(writeStdout, parseApiResponse('rules-list', await parseHttpResponse(response, token)));
1906
+ return 0;
1907
+ }
1908
+ if (parsed.command === 'rules-get') {
1909
+ const response = await fetchImplementation(notificationRuleEndpoint, {
1910
+ method: 'GET', headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1911
+ });
1912
+ writeJson(writeStdout, parseApiResponse('rules-get', await parseHttpResponse(response, token)));
1913
+ return 0;
1914
+ }
1915
+ if (parsed.command === 'rules-set') {
1916
+ const response = await fetchImplementation(notificationRuleEndpoint, {
1917
+ method: 'PUT',
1918
+ headers: { ...headers, 'Content-Type': 'application/json' },
1919
+ body: JSON.stringify({ transactionRef: parsed.transactionRef, ...notificationRulePayload }),
1920
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1921
+ });
1922
+ writeJson(writeStdout, parseApiResponse('rules-set', await parseHttpResponse(response, token)));
1923
+ return 0;
1924
+ }
1925
+ if (parsed.command === 'rules-delete') {
1926
+ const response = await fetchImplementation(notificationRuleEndpoint, {
1927
+ method: 'DELETE', headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1928
+ });
1929
+ writeJson(writeStdout, parseApiResponse('rules-delete', await parseHttpResponse(response, token)));
1930
+ return 0;
1931
+ }
1932
+ if (parsed.command === 'rules-scan-contract') {
1933
+ const contract = readContractPdf(parsed.contract);
1934
+ const response = await fetchImplementation(`${baseUrl}/api/agent/v1/notification-rules/extract-renewal`, {
1935
+ method: 'POST',
1936
+ headers: { ...headers, 'Content-Type': 'application/json' },
1937
+ body: JSON.stringify({
1938
+ filename: nodePath.basename(parsed.contract),
1939
+ mimeType: 'application/pdf',
1940
+ contentBase64: contract.toString('base64'),
1941
+ }),
1942
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1943
+ });
1944
+ writeJson(writeStdout, parseApiResponse('rules-scan-contract', await parseHttpResponse(response, token)));
1945
+ return 0;
1946
+ }
1501
1947
  if (parsed.command === 'ask-partner') {
1502
1948
  const response = await fetchImplementation(`${baseUrl}/api/agent/v1/transaction-explanation-requests`, {
1503
1949
  method: 'POST',
package/dist/contracts.js CHANGED
@@ -27,6 +27,114 @@ function rejectUnknownFields(value, allowed, label) {
27
27
  if (unknown)
28
28
  throw new UsageError(`${label} contains unknown field: ${unknown}`);
29
29
  }
30
+ export function validateNotificationRulePayload(value) {
31
+ const payload = requireObject(value, 'notification rule payload');
32
+ rejectUnknownFields(payload, new Set(['amountChange', 'renewalReminder', 'delivery']), 'notification rule payload');
33
+ const amountChange = requireObject(payload.amountChange, 'amountChange');
34
+ const renewalReminder = requireObject(payload.renewalReminder, 'renewalReminder');
35
+ const delivery = requireObject(payload.delivery, 'delivery');
36
+ rejectUnknownFields(amountChange, new Set(['enabled', 'comparison', 'baselinePence']), 'amountChange');
37
+ rejectUnknownFields(renewalReminder, new Set(['enabled', 'renewalDate', 'leadDays']), 'renewalReminder');
38
+ rejectUnknownFields(delivery, new Set(['email']), 'delivery');
39
+ if (typeof amountChange.enabled !== 'boolean')
40
+ throw new UsageError('amountChange.enabled must be true or false');
41
+ if (amountChange.comparison !== 'increase' && amountChange.comparison !== 'any') {
42
+ throw new UsageError('amountChange.comparison must be increase or any');
43
+ }
44
+ if (!Number.isSafeInteger(amountChange.baselinePence) || Number(amountChange.baselinePence) <= 0) {
45
+ throw new UsageError('amountChange.baselinePence must be a positive integer');
46
+ }
47
+ if (typeof renewalReminder.enabled !== 'boolean')
48
+ throw new UsageError('renewalReminder.enabled must be true or false');
49
+ if (renewalReminder.renewalDate !== null && !isIsoDate(renewalReminder.renewalDate)) {
50
+ throw new UsageError('renewalReminder.renewalDate must be YYYY-MM-DD or null');
51
+ }
52
+ if (renewalReminder.enabled && renewalReminder.renewalDate === null) {
53
+ throw new UsageError('renewalReminder.renewalDate is required when enabled');
54
+ }
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
+ }
58
+ if (typeof delivery.email !== 'boolean') {
59
+ throw new UsageError('delivery.email must be true or false');
60
+ }
61
+ if (!amountChange.enabled && !renewalReminder.enabled) {
62
+ throw new UsageError('At least one notification rule must be enabled');
63
+ }
64
+ return {
65
+ amountChange: {
66
+ enabled: amountChange.enabled,
67
+ comparison: amountChange.comparison,
68
+ baselinePence: Number(amountChange.baselinePence),
69
+ },
70
+ renewalReminder: {
71
+ enabled: renewalReminder.enabled,
72
+ renewalDate: renewalReminder.renewalDate,
73
+ leadDays: Number(renewalReminder.leadDays),
74
+ },
75
+ delivery: { email: delivery.email },
76
+ };
77
+ }
78
+ function isNotificationRule(value) {
79
+ if (!isObject(value))
80
+ return false;
81
+ const renewalReminder = value.renewalReminder;
82
+ const delivery = value.delivery;
83
+ if (!isObject(renewalReminder) || !isObject(delivery))
84
+ return false;
85
+ try {
86
+ validateNotificationRulePayload({
87
+ amountChange: value.amountChange,
88
+ renewalReminder: {
89
+ enabled: renewalReminder.enabled,
90
+ renewalDate: renewalReminder.renewalDate,
91
+ leadDays: renewalReminder.leadDays,
92
+ },
93
+ delivery: { email: delivery.email },
94
+ });
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ return hasOnlyFields(value, [
100
+ 'id', 'transactionRef', 'merchantName', 'currency', 'sourceAmountPence', 'amountChange',
101
+ 'renewalReminder', 'delivery', 'createdAt', 'updatedAt',
102
+ ])
103
+ && typeof value.id === 'string'
104
+ && typeof value.transactionRef === 'string'
105
+ && typeof value.merchantName === 'string'
106
+ && typeof value.currency === 'string'
107
+ && Number.isSafeInteger(value.sourceAmountPence)
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));
114
+ }
115
+ function isNotificationRuleResponse(value) {
116
+ return isObject(value)
117
+ && hasOnlyFields(value, ['rule'])
118
+ && (value.rule === null || isNotificationRule(value.rule));
119
+ }
120
+ function isNotificationRuleListResponse(value) {
121
+ return isObject(value)
122
+ && hasOnlyFields(value, ['rules'])
123
+ && Array.isArray(value.rules)
124
+ && value.rules.every(isNotificationRule);
125
+ }
126
+ function isNotificationRuleDeleteResponse(value) {
127
+ return isObject(value)
128
+ && hasOnlyFields(value, ['deleted', 'transactionRef'])
129
+ && value.deleted === true
130
+ && typeof value.transactionRef === 'string';
131
+ }
132
+ function isRenewalExtractionResponse(value) {
133
+ return isObject(value)
134
+ && hasOnlyFields(value, ['renewalDate', 'confidence'])
135
+ && (value.renewalDate === null || isIsoDate(value.renewalDate))
136
+ && (value.confidence === 'high' || value.confidence === 'medium' || value.confidence === 'low');
137
+ }
30
138
  function validateSplit(value, index, splitIndex) {
31
139
  const label = `assignments[${index}].categorySplits[${splitIndex}]`;
32
140
  const split = requireObject(value, label);
@@ -193,6 +301,36 @@ export function validateBudgetUpdatePayload(value) {
193
301
  });
194
302
  return { allocations };
195
303
  }
304
+ export function validateReceiptConfirmation(value) {
305
+ const receipt = requireObject(value, 'receipt');
306
+ rejectUnknownFields(receipt, new Set(['schemaVersion', 'currency', 'receiptItems']), 'receipt');
307
+ if (receipt.schemaVersion !== 1)
308
+ throw new UsageError('receipt.schemaVersion must be 1');
309
+ if (typeof receipt.currency !== 'string' || !/^[A-Z]{3}$/.test(receipt.currency)) {
310
+ throw new UsageError('receipt.currency must be a three-letter uppercase code');
311
+ }
312
+ if (!Array.isArray(receipt.receiptItems) || receipt.receiptItems.length < 1 || receipt.receiptItems.length > 200) {
313
+ throw new UsageError('receipt.receiptItems must contain between 1 and 200 items');
314
+ }
315
+ const receiptItems = receipt.receiptItems.map((value, index) => {
316
+ const label = `receipt.receiptItems[${index}]`;
317
+ const item = requireObject(value, label);
318
+ rejectUnknownFields(item, new Set(['id', 'label', 'amountPence']), label);
319
+ const id = requireString(item.id, `${label}.id`);
320
+ const itemLabel = requireString(item.label, `${label}.label`);
321
+ if (id.length > 80 || itemLabel.length > 160)
322
+ throw new UsageError(`${label} is too long`);
323
+ if (!Number.isSafeInteger(item.amountPence)) {
324
+ throw new UsageError(`${label}.amountPence must be a signed safe integer`);
325
+ }
326
+ return {
327
+ id,
328
+ label: itemLabel,
329
+ amountPence: Number(item.amountPence),
330
+ };
331
+ });
332
+ return { schemaVersion: 1, currency: receipt.currency, receiptItems };
333
+ }
196
334
  function isLineItemMap(value) {
197
335
  if (!isObject(value))
198
336
  return false;
@@ -254,9 +392,6 @@ function isTransaction(value) {
254
392
  && typeof value.date === 'string'
255
393
  && value.status === 'booked'
256
394
  && isAccountRef(value.accountRef)
257
- && typeof value.accountId === 'string'
258
- && typeof value.accountDocId === 'string'
259
- && typeof value.requisitionId === 'string'
260
395
  && (value.scope === 'personal' || value.scope === 'joint')
261
396
  && (value.categoryId === null || typeof value.categoryId === 'string')
262
397
  && (value.lineItemId === null || typeof value.lineItemId === 'string')
@@ -726,6 +861,55 @@ function isGoalDeleteResponse(value) {
726
861
  && typeof value.deletedGoalId === 'string'
727
862
  && value.deletedGoalId.trim().length > 0);
728
863
  }
864
+ function isReceiptConfirmation(value) {
865
+ try {
866
+ validateReceiptConfirmation(value);
867
+ return true;
868
+ }
869
+ catch {
870
+ return false;
871
+ }
872
+ }
873
+ function isReceiptEvidence(value) {
874
+ if (!isObject(value))
875
+ return false;
876
+ const { revision, receiptTotalPence, confirmedAt, sourceSurface, ...confirmation } = value;
877
+ return (hasOnlyFields(value, [
878
+ 'schemaVersion',
879
+ 'currency',
880
+ 'receiptItems',
881
+ 'revision',
882
+ 'receiptTotalPence',
883
+ 'confirmedAt',
884
+ 'sourceSurface',
885
+ ])
886
+ && isReceiptConfirmation(confirmation)
887
+ && Number.isSafeInteger(revision)
888
+ && Number(revision) > 0
889
+ && Number.isSafeInteger(receiptTotalPence)
890
+ && isIsoDateTime(confirmedAt)
891
+ && (sourceSurface === 'web' || sourceSurface === 'agent_api'));
892
+ }
893
+ function isReceiptExtractResponse(value) {
894
+ if (!isObject(value) || !hasOnlyFields(value, ['draft']) || !isObject(value.draft))
895
+ return false;
896
+ const { warnings, ...confirmation } = value.draft;
897
+ return (isReceiptConfirmation(confirmation)
898
+ && Array.isArray(warnings)
899
+ && warnings.length <= 20
900
+ && warnings.every((warning) => (warning === 'currency_unclear' || warning === 'total_unclear' || warning === 'item_unclear')));
901
+ }
902
+ function isReceiptLookupResponse(value) {
903
+ return isObject(value)
904
+ && hasOnlyFields(value, ['receipt'])
905
+ && (value.receipt === null || isReceiptEvidence(value.receipt));
906
+ }
907
+ function isReceiptMutationResponse(value) {
908
+ return isObject(value) && hasOnlyFields(value, ['receipt']) && isReceiptEvidence(value.receipt);
909
+ }
910
+ function isReceiptDeleteResponse(value) {
911
+ return isObject(value) && hasOnlyFields(value, ['deleted']) && value.deleted === true;
912
+ }
729
913
  export function parseApiResponse(command, value) {
730
914
  const valid = command === 'accounts'
731
915
  ? isAccountsResponse(value)
@@ -749,15 +933,31 @@ export function parseApiResponse(command, value) {
749
933
  ? isLineItemMutationResponse(value)
750
934
  : command === 'transactions'
751
935
  ? isTransactionsResponse(value)
752
- : command === 'assign'
753
- ? isAssignmentResponse(value)
754
- : command === 'ask-partner'
755
- ? isPartnerResponse(value)
756
- : command === 'goals-list'
757
- ? isGoalsResponse(value)
758
- : command === 'goals-delete'
759
- ? isGoalDeleteResponse(value)
760
- : isGoalMutationResponse(value);
936
+ : command === 'rules-list'
937
+ ? isNotificationRuleListResponse(value)
938
+ : command === 'rules-get' || command === 'rules-set'
939
+ ? isNotificationRuleResponse(value)
940
+ : command === 'rules-delete'
941
+ ? isNotificationRuleDeleteResponse(value)
942
+ : command === 'rules-scan-contract'
943
+ ? isRenewalExtractionResponse(value)
944
+ : command === 'receipts-extract'
945
+ ? isReceiptExtractResponse(value)
946
+ : command === 'receipts-get'
947
+ ? isReceiptLookupResponse(value)
948
+ : command === 'receipts-attach'
949
+ ? isReceiptMutationResponse(value)
950
+ : command === 'receipts-remove'
951
+ ? isReceiptDeleteResponse(value)
952
+ : command === 'assign'
953
+ ? isAssignmentResponse(value)
954
+ : command === 'ask-partner'
955
+ ? isPartnerResponse(value)
956
+ : command === 'goals-list'
957
+ ? isGoalsResponse(value)
958
+ : command === 'goals-delete'
959
+ ? isGoalDeleteResponse(value)
960
+ : isGoalMutationResponse(value);
761
961
  if (!valid) {
762
962
  const label = command === 'assign' ? 'assignment' : command;
763
963
  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.14.1",
3
+ "version": "0.16.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {