@slothmoney/agent-cli 0.15.0 → 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,21 @@
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
+
3
19
  ## 0.15.0 - 2026-08-21
4
20
 
5
21
  - 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.16.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
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,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, validateNotificationRulePayload, } 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.15.0';
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:',
@@ -50,6 +52,10 @@ export function usageText() {
50
52
  ' sloth-agent rules set --transaction-ref REF --input rule.json [--apply]',
51
53
  ' sloth-agent rules delete --transaction-ref REF [--apply]',
52
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]',
53
59
  ' sloth-agent goals [list] [--base-url URL]',
54
60
  ' sloth-agent goals create --name NAME --target-amount AMOUNT',
55
61
  ' --type keep|spend [--target-month YYYY-MM] [--apply] [--base-url URL]',
@@ -980,6 +986,53 @@ export function rulesGetHelpText() {
980
986
  ...API_ORIGIN_HELP_LINES,
981
987
  ].join('\n');
982
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
+ }
983
1036
  export function rulesSetHelpText() {
984
1037
  return [
985
1038
  'Sloth Agent CLI — rules set',
@@ -1005,13 +1058,13 @@ export function rulesSetHelpText() {
1005
1058
  ' "renewalDate": "2027-07-30",',
1006
1059
  ' "leadDays": 30',
1007
1060
  ' },',
1008
- ' "delivery": { "inApp": true, "email": true }',
1061
+ ' "delivery": { "email": true }',
1009
1062
  ' }',
1010
1063
  '',
1011
1064
  ' comparison accepts increase or any. baselinePence is a positive integer.',
1012
1065
  ' 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.',
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.',
1015
1068
  '',
1016
1069
  'Write behavior:',
1017
1070
  ' Without --apply, Sloth validates the file and prints a local preview.',
@@ -1023,6 +1076,25 @@ export function rulesSetHelpText() {
1023
1076
  ...API_ORIGIN_HELP_LINES,
1024
1077
  ].join('\n');
1025
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
+ }
1026
1098
  export function rulesDeleteHelpText() {
1027
1099
  return [
1028
1100
  'Sloth Agent CLI — rules delete',
@@ -1068,6 +1140,19 @@ export function rulesScanContractHelpText() {
1068
1140
  ...API_ORIGIN_HELP_LINES,
1069
1141
  ].join('\n');
1070
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
+ }
1071
1156
  export function commandHelpText(topic) {
1072
1157
  const helpByTopic = {
1073
1158
  auth: authHelpText,
@@ -1095,6 +1180,11 @@ export function commandHelpText(topic) {
1095
1180
  'rules-set': rulesSetHelpText,
1096
1181
  'rules-delete': rulesDeleteHelpText,
1097
1182
  'rules-scan-contract': rulesScanContractHelpText,
1183
+ receipts: receiptsHelpText,
1184
+ 'receipts-extract': receiptsExtractHelpText,
1185
+ 'receipts-get': receiptsGetHelpText,
1186
+ 'receipts-attach': receiptsAttachHelpText,
1187
+ 'receipts-remove': receiptsRemoveHelpText,
1098
1188
  goals: goalsHelpText,
1099
1189
  'goals-list': goalsListHelpText,
1100
1190
  'goals-create': goalsCreateHelpText,
@@ -1219,11 +1309,46 @@ function readContractPdf(filePath) {
1219
1309
  const message = error instanceof Error ? error.message : String(error);
1220
1310
  throw new UsageError(`Failed to read contract PDF: ${message}`);
1221
1311
  }
1222
- if (file.length === 0 || file.length > 6 * 1024 * 1024 || file.subarray(0, 4).toString() !== '%PDF') {
1312
+ if (file.length === 0 || file.length > MAX_CONTRACT_PDF_BYTES || file.subarray(0, 4).toString() !== '%PDF') {
1223
1313
  throw new UsageError('Contract must be a PDF no larger than 6 MB');
1224
1314
  }
1225
1315
  return file;
1226
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
+ }
1227
1352
  function buildTransactionsQuery(filters) {
1228
1353
  const params = new URLSearchParams();
1229
1354
  if (filters.uncategorized !== undefined) {
@@ -1457,6 +1582,34 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1457
1582
  });
1458
1583
  return 0;
1459
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
+ }
1460
1613
  const notificationRulePayload = parsed.command === 'rules-set'
1461
1614
  ? validateNotificationRulePayload(readNotificationRuleFile(parsed.input))
1462
1615
  : undefined;
@@ -1497,6 +1650,58 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1497
1650
  const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
1498
1651
  token = credential.token;
1499
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
+ }
1500
1705
  if (parsed.command === 'accounts-update') {
1501
1706
  const endpoint = `${baseUrl}/api/agent/v1/accounts/${encodeURIComponent(parsed.accountRef)}`;
1502
1707
  const response = await fetchImplementation(endpoint, {
@@ -1730,8 +1935,9 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1730
1935
  method: 'POST',
1731
1936
  headers: { ...headers, 'Content-Type': 'application/json' },
1732
1937
  body: JSON.stringify({
1733
- fileBase64: contract.toString('base64'),
1938
+ filename: nodePath.basename(parsed.contract),
1734
1939
  mimeType: 'application/pdf',
1940
+ contentBase64: contract.toString('base64'),
1735
1941
  }),
1736
1942
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1737
1943
  });
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) {
@@ -289,6 +301,36 @@ export function validateBudgetUpdatePayload(value) {
289
301
  });
290
302
  return { allocations };
291
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
+ }
292
334
  function isLineItemMap(value) {
293
335
  if (!isObject(value))
294
336
  return false;
@@ -819,6 +861,55 @@ function isGoalDeleteResponse(value) {
819
861
  && typeof value.deletedGoalId === 'string'
820
862
  && value.deletedGoalId.trim().length > 0);
821
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
+ }
822
913
  export function parseApiResponse(command, value) {
823
914
  const valid = command === 'accounts'
824
915
  ? isAccountsResponse(value)
@@ -850,15 +941,23 @@ export function parseApiResponse(command, value) {
850
941
  ? isNotificationRuleDeleteResponse(value)
851
942
  : command === 'rules-scan-contract'
852
943
  ? 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);
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);
862
961
  if (!valid) {
863
962
  const label = command === 'assign' ? 'assignment' : command;
864
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.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {