@slothmoney/agent-cli 0.11.0 → 0.13.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 +86 -4
- package/dist/args.js +39 -8
- package/dist/cli.js +67 -18
- package/dist/contracts.js +209 -38
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0 - 2026-08-19
|
|
4
|
+
|
|
5
|
+
- Let `assign` share or unshare an owned booked personal transaction, update
|
|
6
|
+
its ratio and exclusive amounts in pence, and combine sharing with category
|
|
7
|
+
assignment in one atomic item.
|
|
8
|
+
- Add `transactions --shared[=true|false]` and strict validation for persisted
|
|
9
|
+
sharing results, including the resulting joint-budget contribution.
|
|
10
|
+
- Keep preview local and credential-free while printing the exact payload that
|
|
11
|
+
apply mode will send.
|
|
12
|
+
|
|
13
|
+
## 0.12.0 - 2026-08-16
|
|
14
|
+
|
|
15
|
+
- Add read-only `budget status` for current-period assigned, spent, and
|
|
16
|
+
available category amounts, including refresh and unallocated-activity
|
|
17
|
+
signals for safer automated budget review.
|
|
18
|
+
|
|
3
19
|
## 0.11.0 - 2026-08-16
|
|
4
20
|
|
|
5
21
|
- Add preview-by-default `budget move` for atomically moving current assigned
|
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.13.0 -- sloth-agent --help
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
## Authenticate
|
|
@@ -94,8 +94,8 @@ remotely in **Sloth Money Settings > Developer access**.
|
|
|
94
94
|
|
|
95
95
|
## Commands
|
|
96
96
|
|
|
97
|
-
An assignment
|
|
98
|
-
|
|
97
|
+
An assignment can change an owned transaction's sharing, categorisation, or
|
|
98
|
+
both.
|
|
99
99
|
|
|
100
100
|
Every command has built-in reference documentation covering its inputs,
|
|
101
101
|
options, output, and examples:
|
|
@@ -104,6 +104,7 @@ options, output, and examples:
|
|
|
104
104
|
sloth-agent auth login --help
|
|
105
105
|
sloth-agent accounts --help
|
|
106
106
|
sloth-agent budget --help
|
|
107
|
+
sloth-agent budget status --help
|
|
107
108
|
sloth-agent budget update --help
|
|
108
109
|
sloth-agent budget move --help
|
|
109
110
|
sloth-agent categories --help
|
|
@@ -144,7 +145,8 @@ item is already categorised for the joint budget. For example:
|
|
|
144
145
|
|
|
145
146
|
This transaction is uncategorised personally but categorised as Groceries for
|
|
146
147
|
the joint budget. The `--uncategorized` filter applies to the selected
|
|
147
|
-
assignment scope;
|
|
148
|
+
assignment scope; the transaction's native scope is used when
|
|
149
|
+
`--assignment-scope` is omitted.
|
|
148
150
|
|
|
149
151
|
### Categorise a transaction end to end
|
|
150
152
|
|
|
@@ -237,6 +239,71 @@ The transaction should also disappear from the matching `--uncategorized`
|
|
|
237
239
|
query. Confirm that an existing assignment in the other scope was not changed.
|
|
238
240
|
Assignments do not create a separate list.
|
|
239
241
|
|
|
242
|
+
### Share and categorise a transaction
|
|
243
|
+
|
|
244
|
+
Find an owned, unshared booked transaction:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
sloth-agent transactions --shared=false --q "sainsbury" --limit 20
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Copy its exact `transactionRef` into `assignments.json`. This example shares
|
|
251
|
+
the transaction 60/40, keeps £5 for you personally, and categorises the shared
|
|
252
|
+
remainder as Groceries in Joint:
|
|
253
|
+
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"assignments": [
|
|
257
|
+
{
|
|
258
|
+
"transactionRef": "PASTE_THE_EXACT_TRANSACTION_REF_HERE",
|
|
259
|
+
"sharing": {
|
|
260
|
+
"isShared": true,
|
|
261
|
+
"shareRatio": 0.6,
|
|
262
|
+
"userExclusiveAmountPence": 500,
|
|
263
|
+
"partnerExclusiveAmountPence": 0
|
|
264
|
+
},
|
|
265
|
+
"assignmentScope": "joint",
|
|
266
|
+
"categoryId": "groceries"
|
|
267
|
+
}
|
|
268
|
+
]
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Preview stays local and does not load credentials or call the API:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
sloth-agent assign --input assignments.json
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Apply with a token created using **Allow changes**, then read back the same
|
|
279
|
+
state shown in the Web App:
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
sloth-agent assign --input assignments.json --apply
|
|
283
|
+
sloth-agent transactions --shared=true --q "sainsbury" --limit 20
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
When `sharing` contains only `"isShared": true`, a first share uses the
|
|
287
|
+
couple's saved ratio, falling back to `0.5`, and shares the full amount. On an
|
|
288
|
+
already shared transaction, omitted split fields preserve their current values.
|
|
289
|
+
Set both exclusive pence fields to zero to share the full amount again.
|
|
290
|
+
|
|
291
|
+
To unshare, send `"sharing": { "isShared": false }` without ratio or exclusive
|
|
292
|
+
fields. Sloth clears the active split but keeps the Joint category dormant, so
|
|
293
|
+
sharing it again restores that category. Current-period Joint pay income is
|
|
294
|
+
reconciled; interest and completed periods keep their existing behaviour.
|
|
295
|
+
|
|
296
|
+
Sharing is available only for your booked personal-account transactions when
|
|
297
|
+
you have an active partner and Joint budget. Partner-owned rows and native
|
|
298
|
+
joint-account rows cannot be changed this way. Foreign-currency rows can still
|
|
299
|
+
be shared for settlement, but their returned contribution has `eligible: false`
|
|
300
|
+
and `included: false`.
|
|
301
|
+
|
|
302
|
+
If a combined item omits `assignmentScope`, the category uses Joint when you
|
|
303
|
+
have no exclusive amount and Personal when you do. Category-only items retain
|
|
304
|
+
their existing Personal/native default. Each item commits atomically, while a
|
|
305
|
+
bulk request remains best-effort across items.
|
|
306
|
+
|
|
240
307
|
### Other workflows
|
|
241
308
|
|
|
242
309
|
Read a personal or joint budget. Omit `--period` to use Sloth's current budget period:
|
|
@@ -249,6 +316,21 @@ The result includes the budget period and status, currency, the effective plan,
|
|
|
249
316
|
stored funding amounts when available, categories, line items, and planned
|
|
250
317
|
amounts in pence.
|
|
251
318
|
|
|
319
|
+
Read current assigned, spent, and available money without aggregating
|
|
320
|
+
transactions yourself:
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
sloth-agent budget status --scope personal
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
The server applies its normal once-per-UTC-day automatic transaction refresh
|
|
327
|
+
policy before returning the current Sloth period dates and signed booked
|
|
328
|
+
activity. For each category, `availablePence` is
|
|
329
|
+
`assignedPence - spentPence`; a negative value is over budget, and refunds
|
|
330
|
+
reduce `spentPence`. Check `refresh`,
|
|
331
|
+
`activity.uncategorizedSpentPence`, and `activity.unmappedSpentPence` before
|
|
332
|
+
using the result to suggest a reallocation. This command is read-only.
|
|
333
|
+
|
|
252
334
|
Update selected line-item amounts by creating `budget.json`:
|
|
253
335
|
|
|
254
336
|
```json
|
package/dist/args.js
CHANGED
|
@@ -155,6 +155,18 @@ function parseTransactions(args) {
|
|
|
155
155
|
filters.uncategorized = setOnce(filters.uncategorized, value === 'true', '--uncategorized');
|
|
156
156
|
continue;
|
|
157
157
|
}
|
|
158
|
+
if (argument === '--shared') {
|
|
159
|
+
filters.shared = setOnce(filters.shared, true, '--shared');
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (argument.startsWith('--shared=')) {
|
|
163
|
+
const value = argument.slice('--shared='.length);
|
|
164
|
+
if (value !== 'true' && value !== 'false') {
|
|
165
|
+
throw new UsageError('--shared must be true or false');
|
|
166
|
+
}
|
|
167
|
+
filters.shared = setOnce(filters.shared, value === 'true', '--shared');
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
158
170
|
const [name, inlineValue] = argument.includes('=')
|
|
159
171
|
? argument.split(/=(.*)/s, 2)
|
|
160
172
|
: [argument, undefined];
|
|
@@ -372,21 +384,38 @@ function parseInvestments(args, baseUrl) {
|
|
|
372
384
|
return withBaseUrl({ command: 'investments', ...(accountRef ? { accountRef } : {}) }, baseUrl);
|
|
373
385
|
}
|
|
374
386
|
function parseBudget(args, baseUrl) {
|
|
375
|
-
const subcommand = args[0] === '
|
|
387
|
+
const subcommand = args[0] === 'status' || args[0] === 'update' || args[0] === 'move'
|
|
388
|
+
? args.shift()
|
|
389
|
+
: undefined;
|
|
390
|
+
const status = subcommand === 'status';
|
|
376
391
|
const update = subcommand === 'update';
|
|
377
392
|
const move = subcommand === 'move';
|
|
378
|
-
const commandLabel =
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
393
|
+
const commandLabel = status
|
|
394
|
+
? 'budget status'
|
|
395
|
+
: update
|
|
396
|
+
? 'budget update'
|
|
397
|
+
: move
|
|
398
|
+
? 'budget move'
|
|
399
|
+
: 'budget';
|
|
400
|
+
const { values, apply } = parseNamedOptions(args, commandLabel, new Set(status
|
|
401
|
+
? ['--scope']
|
|
402
|
+
: update
|
|
403
|
+
? ['--scope', '--period', '--input']
|
|
404
|
+
: move
|
|
405
|
+
? ['--scope', '--period', '--from-category-id', '--to-category-id', '--amount']
|
|
406
|
+
: ['--scope', '--period']));
|
|
384
407
|
if (!update && !move && apply)
|
|
385
|
-
throw new UsageError(
|
|
408
|
+
throw new UsageError(`Unknown ${commandLabel} option: --apply`);
|
|
386
409
|
const scope = requiredOption(values, '--scope', commandLabel);
|
|
387
410
|
if (scope !== 'personal' && scope !== 'joint') {
|
|
388
411
|
throw new UsageError('--scope must be personal or joint');
|
|
389
412
|
}
|
|
413
|
+
if (status) {
|
|
414
|
+
return withBaseUrl({
|
|
415
|
+
command: 'budget-status',
|
|
416
|
+
scope: scope,
|
|
417
|
+
}, baseUrl);
|
|
418
|
+
}
|
|
390
419
|
const period = values.get('--period');
|
|
391
420
|
const common = {
|
|
392
421
|
scope: scope,
|
|
@@ -769,6 +798,8 @@ function helpTopic(argv) {
|
|
|
769
798
|
return undefined;
|
|
770
799
|
}
|
|
771
800
|
if (command === 'budget') {
|
|
801
|
+
if (subcommand === 'status')
|
|
802
|
+
return 'budget-status';
|
|
772
803
|
if (subcommand === 'update')
|
|
773
804
|
return 'budget-update';
|
|
774
805
|
if (subcommand === 'move')
|
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ import { ICON_KEYS } from './category-metadata.js';
|
|
|
4
4
|
import { parseApiResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, } from './contracts.js';
|
|
5
5
|
import { createSystemCredentialStore, secureStorageUnavailableError, } from './credential-store.js';
|
|
6
6
|
import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
|
|
7
|
-
export const CLI_VERSION = '0.
|
|
7
|
+
export const CLI_VERSION = '0.13.0';
|
|
8
8
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
9
9
|
const API_ORIGIN_HELP_LINES = [
|
|
10
10
|
'',
|
|
@@ -30,6 +30,7 @@ export function usageText() {
|
|
|
30
30
|
' sloth-agent accounts remove --account-ref REF [--apply]',
|
|
31
31
|
' sloth-agent investments [--account-ref REF] [--base-url URL]',
|
|
32
32
|
' sloth-agent budget --scope personal|joint [--period YYYY-MM] [--base-url URL]',
|
|
33
|
+
' sloth-agent budget status --scope personal|joint [--base-url URL]',
|
|
33
34
|
' sloth-agent budget update --scope personal|joint [--period YYYY-MM]',
|
|
34
35
|
' --input budget.json [--apply] [--base-url URL]',
|
|
35
36
|
' sloth-agent budget move --scope personal|joint [--period YYYY-MM]',
|
|
@@ -39,7 +40,7 @@ export function usageText() {
|
|
|
39
40
|
' sloth-agent categories rename --category-id ID --name NAME [--apply]',
|
|
40
41
|
' sloth-agent line-items create --scope personal|joint --category-id ID --name NAME [--apply]',
|
|
41
42
|
' sloth-agent line-items rename --scope personal|joint --category-id ID --line-item-id ID --name NAME [--apply]',
|
|
42
|
-
' sloth-agent transactions [--uncategorized[=true|false]] [--limit N]',
|
|
43
|
+
' sloth-agent transactions [--uncategorized[=true|false]] [--shared[=true|false]] [--limit N]',
|
|
43
44
|
' [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--q TEXT]',
|
|
44
45
|
' [--account-id ID] [--category-id ID] [--line-item-id ID]',
|
|
45
46
|
' [--cursor CURSOR] [--base-url URL]',
|
|
@@ -421,6 +422,35 @@ export function budgetHelpText() {
|
|
|
421
422
|
' Categories also include plannedPence and assignedPence.',
|
|
422
423
|
].join('\n');
|
|
423
424
|
}
|
|
425
|
+
export function budgetStatusHelpText() {
|
|
426
|
+
return [
|
|
427
|
+
'Sloth Agent CLI — budget status',
|
|
428
|
+
'',
|
|
429
|
+
'Read assigned, spent, and available money for the current Sloth budget period.',
|
|
430
|
+
'',
|
|
431
|
+
'Usage:',
|
|
432
|
+
' sloth-agent budget status --scope personal|joint [--base-url URL]',
|
|
433
|
+
'',
|
|
434
|
+
'Options:',
|
|
435
|
+
' --scope personal|joint Required. Budget ownership scope.',
|
|
436
|
+
' --base-url URL Optional. Override the API origin.',
|
|
437
|
+
' -h, --help Show this help.',
|
|
438
|
+
...API_ORIGIN_HELP_LINES,
|
|
439
|
+
'',
|
|
440
|
+
'Access and freshness:',
|
|
441
|
+
' This command is read-only, requires agent:read, and never changes the budget.',
|
|
442
|
+
' The server applies its normal once-per-UTC-day automatic transaction refresh policy.',
|
|
443
|
+
' Inspect refresh.status and refresh.reason before relying on the result.',
|
|
444
|
+
'',
|
|
445
|
+
'Output:',
|
|
446
|
+
' categories[].assignedPence is the money assigned to the category.',
|
|
447
|
+
' categories[].spentPence is signed booked activity; refunds reduce it.',
|
|
448
|
+
' categories[].availablePence equals assignedPence minus spentPence.',
|
|
449
|
+
' Negative availablePence means the category is over budget.',
|
|
450
|
+
' activity contains the period dates, transaction count, uncategorizedSpentPence,',
|
|
451
|
+
' and unmappedSpentPence. Review either nonzero value before moving money.',
|
|
452
|
+
].join('\n');
|
|
453
|
+
}
|
|
424
454
|
export function budgetUpdateHelpText() {
|
|
425
455
|
return [
|
|
426
456
|
'Sloth Agent CLI — budget update',
|
|
@@ -512,6 +542,7 @@ export function transactionsHelpText() {
|
|
|
512
542
|
'Options:',
|
|
513
543
|
' --uncategorized[=true|false] Optional. Filter the selected assignment scope by state;',
|
|
514
544
|
' with no value, use true.',
|
|
545
|
+
' --shared[=true|false] Optional. Filter by partner-sharing state; with no value, use true.',
|
|
515
546
|
' --limit N Optional. Integer from 1 to 200; omit for API default.',
|
|
516
547
|
' --start-date YYYY-MM-DD Optional. Include transactions on or after this date.',
|
|
517
548
|
' --end-date YYYY-MM-DD Optional. Include transactions on or before this date.',
|
|
@@ -520,7 +551,7 @@ export function transactionsHelpText() {
|
|
|
520
551
|
' --category-id ID Optional. Filter by category ID.',
|
|
521
552
|
' --line-item-id ID Optional. Filter primary or split assignments by line-item ID.',
|
|
522
553
|
' --assignment-scope SCOPE Optional. Filter assignments by personal or joint.',
|
|
523
|
-
'
|
|
554
|
+
' The transaction\'s native scope is used when omitted.',
|
|
524
555
|
' --cursor CURSOR Optional. Continue from a previous nextCursor.',
|
|
525
556
|
' --base-url URL Optional. Override the API origin.',
|
|
526
557
|
' -h, --help Show this help.',
|
|
@@ -553,8 +584,7 @@ export function assignHelpText() {
|
|
|
553
584
|
return [
|
|
554
585
|
'Sloth Agent CLI — assign',
|
|
555
586
|
'',
|
|
556
|
-
'
|
|
557
|
-
'Validate, preview, or apply category assignments from a JSON file.',
|
|
587
|
+
'Validate, preview, or apply transaction sharing and category assignments from a JSON file.',
|
|
558
588
|
'',
|
|
559
589
|
'Usage:',
|
|
560
590
|
' sloth-agent assign --input FILE [--apply] [--base-url URL]',
|
|
@@ -577,7 +607,11 @@ export function assignHelpText() {
|
|
|
577
607
|
'',
|
|
578
608
|
'Input:',
|
|
579
609
|
' The top-level object must contain an assignments array.',
|
|
580
|
-
' Each assignment requires transactionRef and
|
|
610
|
+
' Each assignment requires transactionRef and at least one category operation or sharing object.',
|
|
611
|
+
' sharing.isShared is required. shareRatio is optional from 0 to 1 and is your share.',
|
|
612
|
+
' userExclusiveAmountPence and partnerExclusiveAmountPence are optional nonnegative integers.',
|
|
613
|
+
' Omitted split values use saved defaults for a first share and preserve an existing split.',
|
|
614
|
+
' Set sharing.isShared to false on its own to unshare and retain the dormant joint category.',
|
|
581
615
|
' Copy the exact transactionRef from transactions output and categoryId from',
|
|
582
616
|
' categories output. The example values below are placeholders.',
|
|
583
617
|
' Set categoryId to null to clear an assignment.',
|
|
@@ -589,7 +623,8 @@ export function assignHelpText() {
|
|
|
589
623
|
' a split lineItemId is optional.',
|
|
590
624
|
' incomeSubtype is optional and accepts "pay", "interest", or null.',
|
|
591
625
|
' assignmentScope is optional and accepts "personal" or "joint".',
|
|
592
|
-
'
|
|
626
|
+
' The transaction\'s native scope is used when assignmentScope is omitted for category-only requests.',
|
|
627
|
+
' Combined requests use Joint when you have no exclusive amount and Personal when you do.',
|
|
593
628
|
'',
|
|
594
629
|
'Workflow:',
|
|
595
630
|
' sloth-agent categories',
|
|
@@ -603,7 +638,8 @@ export function assignHelpText() {
|
|
|
603
638
|
' "assignments": [',
|
|
604
639
|
' {',
|
|
605
640
|
' "transactionRef": "PASTE_THE_EXACT_TRANSACTION_REF_HERE",',
|
|
606
|
-
' "
|
|
641
|
+
' "sharing": { "isShared": true, "shareRatio": 0.6 },',
|
|
642
|
+
' "assignmentScope": "joint",',
|
|
607
643
|
' "categoryId": "PASTE_A_CATEGORY_ID_HERE",',
|
|
608
644
|
' "lineItemId": "PASTE_A_LINE_ITEM_ID_HERE"',
|
|
609
645
|
' }',
|
|
@@ -867,6 +903,7 @@ export function commandHelpText(topic) {
|
|
|
867
903
|
'accounts-remove': accountsRemoveHelpText,
|
|
868
904
|
investments: investmentsHelpText,
|
|
869
905
|
budget: budgetHelpText,
|
|
906
|
+
'budget-status': budgetStatusHelpText,
|
|
870
907
|
'budget-move': budgetMoveHelpText,
|
|
871
908
|
'budget-update': budgetUpdateHelpText,
|
|
872
909
|
categories: categoriesHelpText,
|
|
@@ -987,6 +1024,8 @@ function buildTransactionsQuery(filters) {
|
|
|
987
1024
|
if (filters.uncategorized !== undefined) {
|
|
988
1025
|
params.set('uncategorized', String(filters.uncategorized));
|
|
989
1026
|
}
|
|
1027
|
+
if (filters.shared !== undefined)
|
|
1028
|
+
params.set('shared', String(filters.shared));
|
|
990
1029
|
if (filters.limit !== undefined)
|
|
991
1030
|
params.set('limit', String(filters.limit));
|
|
992
1031
|
if (filters.startDate !== undefined)
|
|
@@ -1202,6 +1241,17 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1202
1241
|
});
|
|
1203
1242
|
return 0;
|
|
1204
1243
|
}
|
|
1244
|
+
const assignmentPayload = parsed.command === 'assign'
|
|
1245
|
+
? validateAssignmentPayload(readAssignmentFile(parsed.input))
|
|
1246
|
+
: undefined;
|
|
1247
|
+
if (parsed.command === 'assign' && !parsed.apply) {
|
|
1248
|
+
writeJson(writeStdout, {
|
|
1249
|
+
dryRun: true,
|
|
1250
|
+
endpoint: `${baseUrl}/api/agent/v1/transaction-assignments`,
|
|
1251
|
+
payload: assignmentPayload,
|
|
1252
|
+
});
|
|
1253
|
+
return 0;
|
|
1254
|
+
}
|
|
1205
1255
|
const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
|
|
1206
1256
|
token = credential.token;
|
|
1207
1257
|
const headers = requestHeaders(token);
|
|
@@ -1386,12 +1436,8 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1386
1436
|
return 0;
|
|
1387
1437
|
}
|
|
1388
1438
|
if (parsed.command === 'assign') {
|
|
1389
|
-
const payload =
|
|
1439
|
+
const payload = assignmentPayload;
|
|
1390
1440
|
const endpoint = `${baseUrl}/api/agent/v1/transaction-assignments`;
|
|
1391
|
-
if (!parsed.apply) {
|
|
1392
|
-
writeJson(writeStdout, { dryRun: true, endpoint, payload });
|
|
1393
|
-
return 0;
|
|
1394
|
-
}
|
|
1395
1441
|
const response = await fetchImplementation(endpoint, {
|
|
1396
1442
|
method: 'POST',
|
|
1397
1443
|
headers: {
|
|
@@ -1429,16 +1475,19 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1429
1475
|
writeJson(writeStdout, withListedGoalPriorities(data));
|
|
1430
1476
|
return 0;
|
|
1431
1477
|
}
|
|
1432
|
-
if (parsed.command === 'budget') {
|
|
1478
|
+
if (parsed.command === 'budget' || parsed.command === 'budget-status') {
|
|
1433
1479
|
const query = new URLSearchParams({ scope: parsed.scope });
|
|
1434
|
-
if (parsed.periodKey !== undefined)
|
|
1480
|
+
if (parsed.command === 'budget' && parsed.periodKey !== undefined) {
|
|
1435
1481
|
query.set('periodKey', parsed.periodKey);
|
|
1436
|
-
|
|
1482
|
+
}
|
|
1483
|
+
const response = await fetchImplementation(`${baseUrl}/api/agent/v1/${parsed.command === 'budget' ? 'budgets' : 'budget-status'}?${query.toString()}`, {
|
|
1437
1484
|
method: 'GET',
|
|
1438
|
-
headers
|
|
1485
|
+
headers: parsed.command === 'budget-status'
|
|
1486
|
+
? { ...headers, Prefer: 'wait=45' }
|
|
1487
|
+
: headers,
|
|
1439
1488
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1440
1489
|
});
|
|
1441
|
-
const data = parseApiResponse(
|
|
1490
|
+
const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
|
|
1442
1491
|
writeJson(writeStdout, data);
|
|
1443
1492
|
return 0;
|
|
1444
1493
|
}
|
package/dist/contracts.js
CHANGED
|
@@ -46,6 +46,7 @@ function validateAssignment(value, index) {
|
|
|
46
46
|
const assignment = requireObject(value, label);
|
|
47
47
|
rejectUnknownFields(assignment, new Set([
|
|
48
48
|
'transactionRef',
|
|
49
|
+
'sharing',
|
|
49
50
|
'assignmentScope',
|
|
50
51
|
'categoryId',
|
|
51
52
|
'lineItemId',
|
|
@@ -53,6 +54,48 @@ function validateAssignment(value, index) {
|
|
|
53
54
|
'incomeSubtype',
|
|
54
55
|
]), label);
|
|
55
56
|
requireString(assignment.transactionRef, `${label}.transactionRef`);
|
|
57
|
+
let sharing;
|
|
58
|
+
if (assignment.sharing !== undefined) {
|
|
59
|
+
const sharingValue = requireObject(assignment.sharing, `${label}.sharing`);
|
|
60
|
+
rejectUnknownFields(sharingValue, new Set([
|
|
61
|
+
'isShared',
|
|
62
|
+
'shareRatio',
|
|
63
|
+
'userExclusiveAmountPence',
|
|
64
|
+
'partnerExclusiveAmountPence',
|
|
65
|
+
]), `${label}.sharing`);
|
|
66
|
+
if (typeof sharingValue.isShared !== 'boolean') {
|
|
67
|
+
throw new UsageError(`${label}.sharing.isShared must be true or false`);
|
|
68
|
+
}
|
|
69
|
+
if (sharingValue.shareRatio !== undefined
|
|
70
|
+
&& (typeof sharingValue.shareRatio !== 'number'
|
|
71
|
+
|| !Number.isFinite(sharingValue.shareRatio)
|
|
72
|
+
|| sharingValue.shareRatio < 0
|
|
73
|
+
|| sharingValue.shareRatio > 1)) {
|
|
74
|
+
throw new UsageError(`${label}.sharing.shareRatio must be a number from 0 to 1`);
|
|
75
|
+
}
|
|
76
|
+
for (const field of ['userExclusiveAmountPence', 'partnerExclusiveAmountPence']) {
|
|
77
|
+
if (sharingValue[field] !== undefined
|
|
78
|
+
&& (!Number.isSafeInteger(sharingValue[field]) || Number(sharingValue[field]) < 0)) {
|
|
79
|
+
throw new UsageError(`${label}.sharing.${field} must be a nonnegative safe integer`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (sharingValue.isShared === false
|
|
83
|
+
&& (sharingValue.shareRatio !== undefined
|
|
84
|
+
|| sharingValue.userExclusiveAmountPence !== undefined
|
|
85
|
+
|| sharingValue.partnerExclusiveAmountPence !== undefined)) {
|
|
86
|
+
throw new UsageError(`${label}.sharing cannot include split fields when isShared is false`);
|
|
87
|
+
}
|
|
88
|
+
sharing = {
|
|
89
|
+
isShared: sharingValue.isShared,
|
|
90
|
+
...(sharingValue.shareRatio === undefined ? {} : { shareRatio: sharingValue.shareRatio }),
|
|
91
|
+
...(sharingValue.userExclusiveAmountPence === undefined
|
|
92
|
+
? {}
|
|
93
|
+
: { userExclusiveAmountPence: Number(sharingValue.userExclusiveAmountPence) }),
|
|
94
|
+
...(sharingValue.partnerExclusiveAmountPence === undefined
|
|
95
|
+
? {}
|
|
96
|
+
: { partnerExclusiveAmountPence: Number(sharingValue.partnerExclusiveAmountPence) }),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
56
99
|
if (assignment.assignmentScope !== undefined
|
|
57
100
|
&& assignment.assignmentScope !== 'personal'
|
|
58
101
|
&& assignment.assignmentScope !== 'joint') {
|
|
@@ -83,11 +126,20 @@ function validateAssignment(value, index) {
|
|
|
83
126
|
const hasCategory = typeof assignment.categoryId === 'string' && assignment.categoryId.trim().length > 0;
|
|
84
127
|
const isClear = assignment.categoryId === null && (!categorySplits || categorySplits.length === 0);
|
|
85
128
|
const hasSplits = Array.isArray(categorySplits) && categorySplits.length > 0;
|
|
86
|
-
|
|
129
|
+
const hasCategoryOperation = hasCategory || isClear || hasSplits;
|
|
130
|
+
if (!hasCategoryOperation && sharing === undefined) {
|
|
87
131
|
throw new UsageError(`${label}.categoryId or categorySplits is required`);
|
|
88
132
|
}
|
|
133
|
+
if (!hasCategoryOperation
|
|
134
|
+
&& (assignment.assignmentScope !== undefined
|
|
135
|
+
|| assignment.lineItemId !== undefined
|
|
136
|
+
|| assignment.incomeSubtype !== undefined
|
|
137
|
+
|| assignment.categorySplits !== undefined)) {
|
|
138
|
+
throw new UsageError(`${label} category options require categoryId or categorySplits`);
|
|
139
|
+
}
|
|
89
140
|
return {
|
|
90
141
|
transactionRef: assignment.transactionRef,
|
|
142
|
+
...(sharing === undefined ? {} : { sharing }),
|
|
91
143
|
...(assignment.assignmentScope !== undefined
|
|
92
144
|
? { assignmentScope: assignment.assignmentScope }
|
|
93
145
|
: {}),
|
|
@@ -227,37 +279,92 @@ function isTransaction(value) {
|
|
|
227
279
|
|| value.incomeSubtype === 'pay'
|
|
228
280
|
|| value.incomeSubtype === 'interest'));
|
|
229
281
|
}
|
|
282
|
+
const REFRESH_STATUSES = new Set(['skipped', 'completed', 'in_progress', 'partial', 'failed']);
|
|
283
|
+
const REFRESH_REASONS = new Set([
|
|
284
|
+
'all_fetched_today',
|
|
285
|
+
'no_api_connections',
|
|
286
|
+
'no_selected_accounts',
|
|
287
|
+
'refreshed',
|
|
288
|
+
'wait_timeout',
|
|
289
|
+
'account_failures',
|
|
290
|
+
'partial_already_attempted',
|
|
291
|
+
'refresh_error',
|
|
292
|
+
]);
|
|
293
|
+
function isRefreshStatus(value) {
|
|
294
|
+
return (isObject(value)
|
|
295
|
+
&& hasOnlyFields(value, ['status', 'reason', 'utcDate'])
|
|
296
|
+
&& typeof value.status === 'string'
|
|
297
|
+
&& REFRESH_STATUSES.has(value.status)
|
|
298
|
+
&& typeof value.reason === 'string'
|
|
299
|
+
&& REFRESH_REASONS.has(value.reason)
|
|
300
|
+
&& isIsoDate(value.utcDate));
|
|
301
|
+
}
|
|
230
302
|
function isTransactionsResponse(value) {
|
|
231
|
-
const validStatuses = new Set(['skipped', 'completed', 'in_progress', 'partial', 'failed']);
|
|
232
|
-
const validReasons = new Set([
|
|
233
|
-
'all_fetched_today',
|
|
234
|
-
'no_api_connections',
|
|
235
|
-
'no_selected_accounts',
|
|
236
|
-
'refreshed',
|
|
237
|
-
'wait_timeout',
|
|
238
|
-
'account_failures',
|
|
239
|
-
'refresh_error',
|
|
240
|
-
]);
|
|
241
|
-
const refresh = isObject(value) ? value.refresh : undefined;
|
|
242
303
|
return (isObject(value)
|
|
243
304
|
&& Array.isArray(value.transactions)
|
|
244
305
|
&& value.transactions.every(isTransaction)
|
|
245
306
|
&& (value.nextCursor === null || typeof value.nextCursor === 'string')
|
|
246
|
-
&&
|
|
247
|
-
&& typeof refresh.status === 'string'
|
|
248
|
-
&& validStatuses.has(refresh.status)
|
|
249
|
-
&& typeof refresh.reason === 'string'
|
|
250
|
-
&& validReasons.has(refresh.reason)
|
|
251
|
-
&& isIsoDate(refresh.utcDate));
|
|
307
|
+
&& isRefreshStatus(value.refresh));
|
|
252
308
|
}
|
|
253
309
|
function isAssignmentResponse(value) {
|
|
310
|
+
const isResponseSplit = (split) => (isObject(split)
|
|
311
|
+
&& hasOnlyFields(split, ['categoryId', 'amountPence', 'lineItemId'])
|
|
312
|
+
&& typeof split.categoryId === 'string'
|
|
313
|
+
&& isNonnegativeSafeInteger(split.amountPence)
|
|
314
|
+
&& split.amountPence > 0
|
|
315
|
+
&& (split.lineItemId === undefined || typeof split.lineItemId === 'string'));
|
|
316
|
+
const isContribution = (contribution) => (isObject(contribution)
|
|
317
|
+
&& hasOnlyFields(contribution, [
|
|
318
|
+
'eligible', 'included', 'amountPence', 'categoryId', 'lineItemId',
|
|
319
|
+
'categorySplits', 'incomeSubtype',
|
|
320
|
+
])
|
|
321
|
+
&& typeof contribution.eligible === 'boolean'
|
|
322
|
+
&& typeof contribution.included === 'boolean'
|
|
323
|
+
&& isNonnegativeSafeInteger(contribution.amountPence)
|
|
324
|
+
&& contribution.amountPence > 0
|
|
325
|
+
&& (contribution.categoryId === null || typeof contribution.categoryId === 'string')
|
|
326
|
+
&& (contribution.lineItemId === null || typeof contribution.lineItemId === 'string')
|
|
327
|
+
&& Array.isArray(contribution.categorySplits)
|
|
328
|
+
&& contribution.categorySplits.every(isResponseSplit)
|
|
329
|
+
&& (contribution.incomeSubtype === null
|
|
330
|
+
|| contribution.incomeSubtype === 'pay'
|
|
331
|
+
|| contribution.incomeSubtype === 'interest'));
|
|
332
|
+
const isSharing = (sharing) => (isObject(sharing)
|
|
333
|
+
&& hasOnlyFields(sharing, [
|
|
334
|
+
'isShared', 'shareRatio', 'sharedAmountPence', 'userExclusiveAmountPence',
|
|
335
|
+
'partnerExclusiveAmountPence', 'jointBudgetContribution',
|
|
336
|
+
])
|
|
337
|
+
&& typeof sharing.isShared === 'boolean'
|
|
338
|
+
&& typeof sharing.shareRatio === 'number'
|
|
339
|
+
&& Number.isFinite(sharing.shareRatio)
|
|
340
|
+
&& sharing.shareRatio >= 0
|
|
341
|
+
&& sharing.shareRatio <= 1
|
|
342
|
+
&& isNonnegativeSafeInteger(sharing.sharedAmountPence)
|
|
343
|
+
&& isNonnegativeSafeInteger(sharing.userExclusiveAmountPence)
|
|
344
|
+
&& isNonnegativeSafeInteger(sharing.partnerExclusiveAmountPence)
|
|
345
|
+
&& (sharing.jointBudgetContribution === null
|
|
346
|
+
|| isContribution(sharing.jointBudgetContribution)));
|
|
347
|
+
const isCategoryResult = (item) => ((item.assignmentScope === 'personal' || item.assignmentScope === 'joint')
|
|
348
|
+
&& (item.categoryId === null || typeof item.categoryId === 'string')
|
|
349
|
+
&& (item.lineItemId === null || typeof item.lineItemId === 'string')
|
|
350
|
+
&& Array.isArray(item.categorySplits)
|
|
351
|
+
&& item.categorySplits.every(isResponseSplit)
|
|
352
|
+
&& (item.incomeSubtype === null
|
|
353
|
+
|| item.incomeSubtype === 'pay'
|
|
354
|
+
|| item.incomeSubtype === 'interest'));
|
|
254
355
|
return (isObject(value)
|
|
255
356
|
&& Array.isArray(value.succeeded)
|
|
256
357
|
&& value.succeeded.every((item) => (isObject(item)
|
|
358
|
+
&& hasOnlyFields(item, [
|
|
359
|
+
'transactionRef', 'categoryId', 'lineItemId', 'categorySplits',
|
|
360
|
+
'incomeSubtype', 'assignmentScope', 'sharing',
|
|
361
|
+
])
|
|
257
362
|
&& typeof item.transactionRef === 'string'
|
|
258
|
-
&& (item
|
|
363
|
+
&& (isCategoryResult(item) || isSharing(item.sharing))
|
|
364
|
+
&& (item.sharing === undefined || isSharing(item.sharing))))
|
|
259
365
|
&& Array.isArray(value.failed)
|
|
260
366
|
&& value.failed.every((item) => (isObject(item)
|
|
367
|
+
&& hasOnlyFields(item, ['transactionRef', 'error'])
|
|
261
368
|
&& typeof item.error === 'string'
|
|
262
369
|
&& (item.transactionRef === undefined || typeof item.transactionRef === 'string'))));
|
|
263
370
|
}
|
|
@@ -375,6 +482,68 @@ function isBudgetResponse(value) {
|
|
|
375
482
|
&& Array.isArray(value.categories)
|
|
376
483
|
&& value.categories.every(isBudgetCategory));
|
|
377
484
|
}
|
|
485
|
+
function isBudgetStatusResponse(value) {
|
|
486
|
+
if (!isObject(value)
|
|
487
|
+
|| !hasOnlyFields(value, [
|
|
488
|
+
'scope',
|
|
489
|
+
'periodKey',
|
|
490
|
+
'periodStatus',
|
|
491
|
+
'currency',
|
|
492
|
+
'effectiveFromPeriodKey',
|
|
493
|
+
'funding',
|
|
494
|
+
'activity',
|
|
495
|
+
'refresh',
|
|
496
|
+
'categories',
|
|
497
|
+
]))
|
|
498
|
+
return false;
|
|
499
|
+
return ((value.scope === 'personal' || value.scope === 'joint')
|
|
500
|
+
&& typeof value.periodKey === 'string'
|
|
501
|
+
&& /^\d{4}-(0[1-9]|1[0-2])$/.test(value.periodKey)
|
|
502
|
+
&& value.periodStatus === 'current'
|
|
503
|
+
&& isCurrency(value.currency)
|
|
504
|
+
&& typeof value.effectiveFromPeriodKey === 'string'
|
|
505
|
+
&& /^\d{4}-(0[1-9]|1[0-2])$/.test(value.effectiveFromPeriodKey)
|
|
506
|
+
&& (value.funding === null
|
|
507
|
+
|| (isObject(value.funding)
|
|
508
|
+
&& hasOnlyFields(value.funding, ['toAssignPence', 'nextPeriodReservePence'])
|
|
509
|
+
&& isSafeInteger(value.funding.toAssignPence)
|
|
510
|
+
&& isSafeInteger(value.funding.nextPeriodReservePence)))
|
|
511
|
+
&& isObject(value.activity)
|
|
512
|
+
&& hasOnlyFields(value.activity, [
|
|
513
|
+
'startDate',
|
|
514
|
+
'endDate',
|
|
515
|
+
'transactionCount',
|
|
516
|
+
'uncategorizedSpentPence',
|
|
517
|
+
'unmappedSpentPence',
|
|
518
|
+
])
|
|
519
|
+
&& isIsoDate(value.activity.startDate)
|
|
520
|
+
&& isIsoDate(value.activity.endDate)
|
|
521
|
+
&& value.activity.startDate <= value.activity.endDate
|
|
522
|
+
&& isNonnegativeSafeInteger(value.activity.transactionCount)
|
|
523
|
+
&& isSafeInteger(value.activity.uncategorizedSpentPence)
|
|
524
|
+
&& isSafeInteger(value.activity.unmappedSpentPence)
|
|
525
|
+
&& isRefreshStatus(value.refresh)
|
|
526
|
+
&& Array.isArray(value.categories)
|
|
527
|
+
&& value.categories.every((category) => (isObject(category)
|
|
528
|
+
&& hasOnlyFields(category, [
|
|
529
|
+
'id',
|
|
530
|
+
'name',
|
|
531
|
+
'plannedPence',
|
|
532
|
+
'assignedPence',
|
|
533
|
+
'spentPence',
|
|
534
|
+
'availablePence',
|
|
535
|
+
])
|
|
536
|
+
&& typeof category.id === 'string'
|
|
537
|
+
&& category.id.trim().length > 0
|
|
538
|
+
&& typeof category.name === 'string'
|
|
539
|
+
&& category.name.trim().length > 0
|
|
540
|
+
&& isNonnegativeSafeInteger(category.plannedPence)
|
|
541
|
+
&& isSafeInteger(category.assignedPence)
|
|
542
|
+
&& isSafeInteger(category.spentPence)
|
|
543
|
+
&& isSafeInteger(category.availablePence)
|
|
544
|
+
&& Number.isSafeInteger(category.assignedPence - category.spentPence)
|
|
545
|
+
&& category.availablePence === category.assignedPence - category.spentPence)));
|
|
546
|
+
}
|
|
378
547
|
function isBudgetMovementResponse(value) {
|
|
379
548
|
return (isObject(value)
|
|
380
549
|
&& hasOnlyFields(value, [
|
|
@@ -568,25 +737,27 @@ export function parseApiResponse(command, value) {
|
|
|
568
737
|
? isInvestmentsResponse(value)
|
|
569
738
|
: command === 'budget' || command === 'budget-update'
|
|
570
739
|
? isBudgetResponse(value)
|
|
571
|
-
: command === 'budget-
|
|
572
|
-
?
|
|
573
|
-
: command === '
|
|
574
|
-
?
|
|
575
|
-
: command === 'categories
|
|
576
|
-
?
|
|
577
|
-
: command === '
|
|
578
|
-
?
|
|
579
|
-
: command === '
|
|
580
|
-
?
|
|
581
|
-
: command === '
|
|
582
|
-
?
|
|
583
|
-
: command === '
|
|
584
|
-
?
|
|
585
|
-
: command === '
|
|
586
|
-
?
|
|
587
|
-
: command === 'goals-
|
|
588
|
-
?
|
|
589
|
-
:
|
|
740
|
+
: command === 'budget-status'
|
|
741
|
+
? isBudgetStatusResponse(value)
|
|
742
|
+
: command === 'budget-move'
|
|
743
|
+
? isBudgetMovementResponse(value)
|
|
744
|
+
: command === 'categories'
|
|
745
|
+
? isCategoryResponse(value)
|
|
746
|
+
: command === 'categories-create' || command === 'categories-rename'
|
|
747
|
+
? isCategoryMutationResponse(value)
|
|
748
|
+
: command === 'line-items-create' || command === 'line-items-rename'
|
|
749
|
+
? isLineItemMutationResponse(value)
|
|
750
|
+
: command === 'transactions'
|
|
751
|
+
? 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);
|
|
590
761
|
if (!valid) {
|
|
591
762
|
const label = command === 'assign' ? 'assignment' : command;
|
|
592
763
|
throw new ApiError(`Invalid ${label} response from the Agent API`);
|