@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 +16 -0
- package/README.md +48 -2
- package/dist/args.js +101 -0
- package/dist/cli.js +213 -7
- package/dist/contracts.js +119 -20
- package/package.json +1 -1
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.
|
|
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.
|
|
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": { "
|
|
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
|
|
1014
|
-
'
|
|
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 >
|
|
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
|
-
|
|
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(['
|
|
38
|
+
rejectUnknownFields(delivery, new Set(['email']), 'delivery');
|
|
39
39
|
if (typeof amountChange.enabled !== 'boolean')
|
|
40
40
|
throw new UsageError('amountChange.enabled must be true or false');
|
|
41
41
|
if (amountChange.comparison !== 'increase' && amountChange.comparison !== 'any') {
|
|
@@ -52,11 +52,11 @@ export function validateNotificationRulePayload(value) {
|
|
|
52
52
|
if (renewalReminder.enabled && renewalReminder.renewalDate === null) {
|
|
53
53
|
throw new UsageError('renewalReminder.renewalDate is required when enabled');
|
|
54
54
|
}
|
|
55
|
-
if (!Number.isSafeInteger(renewalReminder.leadDays) || Number(renewalReminder.leadDays) <
|
|
56
|
-
throw new UsageError('renewalReminder.leadDays must be an integer from
|
|
55
|
+
if (!Number.isSafeInteger(renewalReminder.leadDays) || Number(renewalReminder.leadDays) < 1 || Number(renewalReminder.leadDays) > 365) {
|
|
56
|
+
throw new UsageError('renewalReminder.leadDays must be an integer from 1 to 365');
|
|
57
57
|
}
|
|
58
|
-
if (
|
|
59
|
-
throw new UsageError('delivery must
|
|
58
|
+
if (typeof delivery.email !== 'boolean') {
|
|
59
|
+
throw new UsageError('delivery.email must be true or false');
|
|
60
60
|
}
|
|
61
61
|
if (!amountChange.enabled && !renewalReminder.enabled) {
|
|
62
62
|
throw new UsageError('At least one notification rule must be enabled');
|
|
@@ -72,17 +72,25 @@ export function validateNotificationRulePayload(value) {
|
|
|
72
72
|
renewalDate: renewalReminder.renewalDate,
|
|
73
73
|
leadDays: Number(renewalReminder.leadDays),
|
|
74
74
|
},
|
|
75
|
-
delivery: {
|
|
75
|
+
delivery: { email: delivery.email },
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
78
|
function isNotificationRule(value) {
|
|
79
79
|
if (!isObject(value))
|
|
80
80
|
return false;
|
|
81
|
+
const renewalReminder = value.renewalReminder;
|
|
82
|
+
const delivery = value.delivery;
|
|
83
|
+
if (!isObject(renewalReminder) || !isObject(delivery))
|
|
84
|
+
return false;
|
|
81
85
|
try {
|
|
82
86
|
validateNotificationRulePayload({
|
|
83
87
|
amountChange: value.amountChange,
|
|
84
|
-
renewalReminder:
|
|
85
|
-
|
|
88
|
+
renewalReminder: {
|
|
89
|
+
enabled: renewalReminder.enabled,
|
|
90
|
+
renewalDate: renewalReminder.renewalDate,
|
|
91
|
+
leadDays: renewalReminder.leadDays,
|
|
92
|
+
},
|
|
93
|
+
delivery: { email: delivery.email },
|
|
86
94
|
});
|
|
87
95
|
}
|
|
88
96
|
catch {
|
|
@@ -97,8 +105,12 @@ function isNotificationRule(value) {
|
|
|
97
105
|
&& typeof value.merchantName === 'string'
|
|
98
106
|
&& typeof value.currency === 'string'
|
|
99
107
|
&& Number.isSafeInteger(value.sourceAmountPence)
|
|
100
|
-
&&
|
|
101
|
-
&&
|
|
108
|
+
&& hasOnlyFields(renewalReminder, ['enabled', 'renewalDate', 'leadDays', 'remindOn'])
|
|
109
|
+
&& (renewalReminder.remindOn === null || isIsoDate(renewalReminder.remindOn))
|
|
110
|
+
&& hasOnlyFields(delivery, ['inApp', 'email'])
|
|
111
|
+
&& delivery.inApp === true
|
|
112
|
+
&& (value.createdAt === null || isIsoDateTime(value.createdAt))
|
|
113
|
+
&& (value.updatedAt === null || isIsoDateTime(value.updatedAt));
|
|
102
114
|
}
|
|
103
115
|
function isNotificationRuleResponse(value) {
|
|
104
116
|
return isObject(value)
|
|
@@ -120,7 +132,7 @@ function isNotificationRuleDeleteResponse(value) {
|
|
|
120
132
|
function isRenewalExtractionResponse(value) {
|
|
121
133
|
return isObject(value)
|
|
122
134
|
&& hasOnlyFields(value, ['renewalDate', 'confidence'])
|
|
123
|
-
&& isIsoDate(value.renewalDate)
|
|
135
|
+
&& (value.renewalDate === null || isIsoDate(value.renewalDate))
|
|
124
136
|
&& (value.confidence === 'high' || value.confidence === 'medium' || value.confidence === 'low');
|
|
125
137
|
}
|
|
126
138
|
function validateSplit(value, index, splitIndex) {
|
|
@@ -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 === '
|
|
854
|
-
?
|
|
855
|
-
: command === '
|
|
856
|
-
?
|
|
857
|
-
: command === '
|
|
858
|
-
?
|
|
859
|
-
: command === '
|
|
860
|
-
?
|
|
861
|
-
:
|
|
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`);
|