@slothmoney/agent-cli 0.10.0 → 0.12.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 +13 -0
- package/README.md +51 -3
- package/dist/args.js +73 -13
- package/dist/cli.js +114 -7
- package/dist/contracts.js +161 -34
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.0 - 2026-08-16
|
|
4
|
+
|
|
5
|
+
- Add read-only `budget status` for current-period assigned, spent, and
|
|
6
|
+
available category amounts, including refresh and unallocated-activity
|
|
7
|
+
signals for safer automated budget review.
|
|
8
|
+
|
|
9
|
+
## 0.11.0 - 2026-08-16
|
|
10
|
+
|
|
11
|
+
- Add preview-by-default `budget move` for atomically moving current assigned
|
|
12
|
+
money between categories or To Assign without changing planned budgets.
|
|
13
|
+
- Accept human-readable currency amounts at the CLI boundary, send integer
|
|
14
|
+
pence to the Agent API, and validate the returned affected balances.
|
|
15
|
+
|
|
3
16
|
## 0.10.0 - 2026-08-15
|
|
4
17
|
|
|
5
18
|
- Require every goal create to specify a positive target amount and Keep or
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Sloth Agent CLI
|
|
2
2
|
|
|
3
|
-
Use your own agent to inspect accounts, investments, and budgets, manage goals, update planned amounts, and categorise transactions through the
|
|
3
|
+
Use your own agent to inspect accounts, investments, and budgets, manage goals, move assigned budget money, update planned amounts, and categorise transactions through the
|
|
4
4
|
[Sloth Money Agent API](https://slothmoney.app/developers/).
|
|
5
5
|
|
|
6
6
|
## Install
|
|
@@ -15,7 +15,7 @@ sloth-agent --version
|
|
|
15
15
|
For a one-off pinned run:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
npm exec --yes --package=@slothmoney/agent-cli@0.
|
|
18
|
+
npm exec --yes --package=@slothmoney/agent-cli@0.12.0 -- sloth-agent --help
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
## Authenticate
|
|
@@ -27,7 +27,7 @@ where the CLI runs.
|
|
|
27
27
|
New tokens are view-only. That is enough for `auth status`, `accounts`, `investments`,
|
|
28
28
|
`budget`, `categories`, `transactions`, and `goals` list. Enable **Allow changes** when
|
|
29
29
|
creating the token only if the CLI must apply assignments, manage categories
|
|
30
|
-
or line items, update planned budgets, manage accounts, ask a partner for an explanation, or manage goals. Token
|
|
30
|
+
or line items, move assigned budget money, update planned budgets, manage accounts, ask a partner for an explanation, or manage goals. Token
|
|
31
31
|
permissions cannot be changed later - revoke and reissue the token instead.
|
|
32
32
|
|
|
33
33
|
### Local computer
|
|
@@ -104,7 +104,9 @@ 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
|
|
109
|
+
sloth-agent budget move --help
|
|
108
110
|
sloth-agent categories --help
|
|
109
111
|
sloth-agent categories create --help
|
|
110
112
|
sloth-agent line-items create --help
|
|
@@ -248,6 +250,21 @@ The result includes the budget period and status, currency, the effective plan,
|
|
|
248
250
|
stored funding amounts when available, categories, line items, and planned
|
|
249
251
|
amounts in pence.
|
|
250
252
|
|
|
253
|
+
Read current assigned, spent, and available money without aggregating
|
|
254
|
+
transactions yourself:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
sloth-agent budget status --scope personal
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
The server applies its normal once-per-UTC-day automatic transaction refresh
|
|
261
|
+
policy before returning the current Sloth period dates and signed booked
|
|
262
|
+
activity. For each category, `availablePence` is
|
|
263
|
+
`assignedPence - spentPence`; a negative value is over budget, and refunds
|
|
264
|
+
reduce `spentPence`. Check `refresh`,
|
|
265
|
+
`activity.uncategorizedSpentPence`, and `activity.unmappedSpentPence` before
|
|
266
|
+
using the result to suggest a reallocation. This command is read-only.
|
|
267
|
+
|
|
251
268
|
Update selected line-item amounts by creating `budget.json`:
|
|
252
269
|
|
|
253
270
|
```json
|
|
@@ -285,6 +302,37 @@ period and everything after it. Earlier and historical periods remain unchanged.
|
|
|
285
302
|
Without `--apply`, the CLI validates the file locally and does not load a token
|
|
286
303
|
or contact Sloth Money. Applying requires a write-enabled token.
|
|
287
304
|
|
|
305
|
+
Move current assigned money between two categories, or use the reserved
|
|
306
|
+
`to-assign` ID to move money to or from To Assign:
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
sloth-agent budget move \
|
|
310
|
+
--scope personal \
|
|
311
|
+
--from-category-id activities \
|
|
312
|
+
--to-category-id groceries \
|
|
313
|
+
--amount 52.95
|
|
314
|
+
|
|
315
|
+
sloth-agent budget move \
|
|
316
|
+
--scope personal \
|
|
317
|
+
--from-category-id activities \
|
|
318
|
+
--to-category-id groceries \
|
|
319
|
+
--amount 52.95 \
|
|
320
|
+
--apply
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Copy category IDs from `sloth-agent budget` output. `--amount` is expressed in
|
|
324
|
+
the budget currency and accepts up to two decimal places; the CLI converts the
|
|
325
|
+
decimal digits exactly and sends a positive safe-integer number of pence to the
|
|
326
|
+
API. Without `--apply`, the command validates and prints the exact request
|
|
327
|
+
without loading credentials or contacting Sloth Money.
|
|
328
|
+
|
|
329
|
+
Applying subtracts and adds the amount atomically, records the movement in the
|
|
330
|
+
budget history, and returns the affected assigned balances. It does not change
|
|
331
|
+
planned line-item amounts or future budget plans. Like the UI, it permits a
|
|
332
|
+
source category or To Assign to become negative; an automated workflow should
|
|
333
|
+
choose donors from its own available-balance policy. Historical periods cannot
|
|
334
|
+
be changed, and applying requires a write-enabled token.
|
|
335
|
+
|
|
288
336
|
Create or rename a custom category. Writes are previews until `--apply` is
|
|
289
337
|
present:
|
|
290
338
|
|
package/dist/args.js
CHANGED
|
@@ -49,16 +49,33 @@ function requireNonEmpty(value, name) {
|
|
|
49
49
|
throw new UsageError(`${name} requires a value`);
|
|
50
50
|
return value;
|
|
51
51
|
}
|
|
52
|
-
function
|
|
52
|
+
function validatePositiveDecimalAmount(value, name) {
|
|
53
53
|
if (!/^\d+(?:\.\d{1,2})?$/.test(value)) {
|
|
54
54
|
throw new UsageError(`${name} must be a positive amount with at most two decimal places`);
|
|
55
55
|
}
|
|
56
|
+
const digits = value.replace('.', '');
|
|
57
|
+
if (!/[1-9]/.test(digits)) {
|
|
58
|
+
throw new UsageError(`${name} must be a positive amount with at most two decimal places`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function parsePositiveDecimalAmount(value, name) {
|
|
62
|
+
validatePositiveDecimalAmount(value, name);
|
|
56
63
|
const amount = Number(value);
|
|
57
64
|
if (!Number.isFinite(amount) || amount <= 0) {
|
|
58
65
|
throw new UsageError(`${name} must be a positive amount with at most two decimal places`);
|
|
59
66
|
}
|
|
60
67
|
return amount;
|
|
61
68
|
}
|
|
69
|
+
function parsePositiveAmountPence(value, name) {
|
|
70
|
+
validatePositiveDecimalAmount(value, name);
|
|
71
|
+
const [wholePounds, fractionalPounds = ''] = value.split('.');
|
|
72
|
+
const amountPence = BigInt(wholePounds) * 100n
|
|
73
|
+
+ BigInt(fractionalPounds.padEnd(2, '0'));
|
|
74
|
+
if (amountPence > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
75
|
+
throw new UsageError(`${name} must be a positive amount with at most two decimal places`);
|
|
76
|
+
}
|
|
77
|
+
return Number(amountPence);
|
|
78
|
+
}
|
|
62
79
|
function parseGoalMonthKey(value, name) {
|
|
63
80
|
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(value)) {
|
|
64
81
|
throw new UsageError(`${name} must be a valid YYYY-MM month`);
|
|
@@ -109,7 +126,7 @@ function parseResourceId(value, option) {
|
|
|
109
126
|
const codePoint = character.codePointAt(0);
|
|
110
127
|
return codePoint !== undefined && (codePoint < 32 || codePoint === 127);
|
|
111
128
|
})) {
|
|
112
|
-
const resource = option === '--
|
|
129
|
+
const resource = option === '--line-item-id' ? 'line-item' : 'category';
|
|
113
130
|
throw new UsageError(`${option} must be a valid ${resource} document ID`);
|
|
114
131
|
}
|
|
115
132
|
return id;
|
|
@@ -355,23 +372,60 @@ function parseInvestments(args, baseUrl) {
|
|
|
355
372
|
return withBaseUrl({ command: 'investments', ...(accountRef ? { accountRef } : {}) }, baseUrl);
|
|
356
373
|
}
|
|
357
374
|
function parseBudget(args, baseUrl) {
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const
|
|
375
|
+
const subcommand = args[0] === 'status' || args[0] === 'update' || args[0] === 'move'
|
|
376
|
+
? args.shift()
|
|
377
|
+
: undefined;
|
|
378
|
+
const status = subcommand === 'status';
|
|
379
|
+
const update = subcommand === 'update';
|
|
380
|
+
const move = subcommand === 'move';
|
|
381
|
+
const commandLabel = status
|
|
382
|
+
? 'budget status'
|
|
383
|
+
: update
|
|
384
|
+
? 'budget update'
|
|
385
|
+
: move
|
|
386
|
+
? 'budget move'
|
|
387
|
+
: 'budget';
|
|
388
|
+
const { values, apply } = parseNamedOptions(args, commandLabel, new Set(status
|
|
389
|
+
? ['--scope']
|
|
390
|
+
: update
|
|
391
|
+
? ['--scope', '--period', '--input']
|
|
392
|
+
: move
|
|
393
|
+
? ['--scope', '--period', '--from-category-id', '--to-category-id', '--amount']
|
|
394
|
+
: ['--scope', '--period']));
|
|
395
|
+
if (!update && !move && apply)
|
|
396
|
+
throw new UsageError(`Unknown ${commandLabel} option: --apply`);
|
|
397
|
+
const scope = requiredOption(values, '--scope', commandLabel);
|
|
365
398
|
if (scope !== 'personal' && scope !== 'joint') {
|
|
366
399
|
throw new UsageError('--scope must be personal or joint');
|
|
367
400
|
}
|
|
401
|
+
if (status) {
|
|
402
|
+
return withBaseUrl({
|
|
403
|
+
command: 'budget-status',
|
|
404
|
+
scope: scope,
|
|
405
|
+
}, baseUrl);
|
|
406
|
+
}
|
|
368
407
|
const period = values.get('--period');
|
|
369
408
|
const common = {
|
|
370
409
|
scope: scope,
|
|
371
410
|
...(period === undefined ? {} : { periodKey: parseGoalMonthKey(period, '--period') }),
|
|
372
411
|
};
|
|
373
|
-
if (!update)
|
|
412
|
+
if (!update && !move)
|
|
374
413
|
return withBaseUrl({ command: 'budget', ...common }, baseUrl);
|
|
414
|
+
if (move) {
|
|
415
|
+
const fromCategoryId = parseResourceId(requiredOption(values, '--from-category-id', commandLabel), '--from-category-id');
|
|
416
|
+
const toCategoryId = parseResourceId(requiredOption(values, '--to-category-id', commandLabel), '--to-category-id');
|
|
417
|
+
if (fromCategoryId === toCategoryId) {
|
|
418
|
+
throw new UsageError('--from-category-id and --to-category-id must differ');
|
|
419
|
+
}
|
|
420
|
+
return withBaseUrl({
|
|
421
|
+
command: 'budget-move',
|
|
422
|
+
...common,
|
|
423
|
+
fromCategoryId,
|
|
424
|
+
toCategoryId,
|
|
425
|
+
amountPence: parsePositiveAmountPence(requiredOption(values, '--amount', commandLabel), '--amount'),
|
|
426
|
+
apply,
|
|
427
|
+
}, baseUrl);
|
|
428
|
+
}
|
|
375
429
|
return withBaseUrl({
|
|
376
430
|
command: 'budget-update',
|
|
377
431
|
...common,
|
|
@@ -527,7 +581,7 @@ function parseGoals(args, baseUrl) {
|
|
|
527
581
|
name = setOnce(name, parseGoalName(value), option);
|
|
528
582
|
}
|
|
529
583
|
else if (option === '--target-amount') {
|
|
530
|
-
targetAmount = setOnce(targetAmount,
|
|
584
|
+
targetAmount = setOnce(targetAmount, parsePositiveDecimalAmount(value, option), option);
|
|
531
585
|
}
|
|
532
586
|
else if (option === '--target-month') {
|
|
533
587
|
targetMonthKey = setOnce(targetMonthKey, parseGoalMonthKey(value, option), option);
|
|
@@ -597,7 +651,7 @@ function parseGoals(args, baseUrl) {
|
|
|
597
651
|
name = setOnce(name, parseGoalName(value), option);
|
|
598
652
|
}
|
|
599
653
|
else if (option === '--target-amount') {
|
|
600
|
-
targetAmount = setOnce(targetAmount,
|
|
654
|
+
targetAmount = setOnce(targetAmount, parsePositiveDecimalAmount(value, option), option);
|
|
601
655
|
}
|
|
602
656
|
else if (option === '--target-month') {
|
|
603
657
|
if (targetMonthKey !== undefined) {
|
|
@@ -732,7 +786,13 @@ function helpTopic(argv) {
|
|
|
732
786
|
return undefined;
|
|
733
787
|
}
|
|
734
788
|
if (command === 'budget') {
|
|
735
|
-
|
|
789
|
+
if (subcommand === 'status')
|
|
790
|
+
return 'budget-status';
|
|
791
|
+
if (subcommand === 'update')
|
|
792
|
+
return 'budget-update';
|
|
793
|
+
if (subcommand === 'move')
|
|
794
|
+
return 'budget-move';
|
|
795
|
+
return 'budget';
|
|
736
796
|
}
|
|
737
797
|
if (command === 'accounts'
|
|
738
798
|
|| command === 'transactions'
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { parseArgs, resolveBaseUrl, } from './args.js';
|
|
3
3
|
import { ICON_KEYS } from './category-metadata.js';
|
|
4
|
-
import { parseApiResponse, validateAssignmentPayload, validateBudgetUpdatePayload, } from './contracts.js';
|
|
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.12.0';
|
|
8
8
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
9
9
|
const API_ORIGIN_HELP_LINES = [
|
|
10
10
|
'',
|
|
@@ -30,8 +30,11 @@ 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]',
|
|
36
|
+
' sloth-agent budget move --scope personal|joint [--period YYYY-MM]',
|
|
37
|
+
' --from-category-id ID --to-category-id ID --amount AMOUNT [--apply]',
|
|
35
38
|
' sloth-agent categories [list] [--base-url URL]',
|
|
36
39
|
' sloth-agent categories create --name NAME --icon-key KEY --type TYPE [--apply]',
|
|
37
40
|
' sloth-agent categories rename --category-id ID --name NAME [--apply]',
|
|
@@ -419,6 +422,35 @@ export function budgetHelpText() {
|
|
|
419
422
|
' Categories also include plannedPence and assignedPence.',
|
|
420
423
|
].join('\n');
|
|
421
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
|
+
}
|
|
422
454
|
export function budgetUpdateHelpText() {
|
|
423
455
|
return [
|
|
424
456
|
'Sloth Agent CLI — budget update',
|
|
@@ -457,6 +489,47 @@ export function budgetUpdateHelpText() {
|
|
|
457
489
|
' Apply mode returns the complete persisted budget response.',
|
|
458
490
|
].join('\n');
|
|
459
491
|
}
|
|
492
|
+
export function budgetMoveHelpText() {
|
|
493
|
+
return [
|
|
494
|
+
'Sloth Agent CLI — budget move',
|
|
495
|
+
'',
|
|
496
|
+
'Preview or move assigned money between categories or To Assign.',
|
|
497
|
+
'',
|
|
498
|
+
'Usage:',
|
|
499
|
+
' sloth-agent budget move --scope personal|joint [--period YYYY-MM] --from-category-id ID --to-category-id ID --amount AMOUNT [--apply] [--base-url URL]',
|
|
500
|
+
'',
|
|
501
|
+
'Required inputs:',
|
|
502
|
+
' --scope personal|joint Budget ownership scope.',
|
|
503
|
+
' --from-category-id ID Source category ID, or to-assign.',
|
|
504
|
+
' --to-category-id ID Destination category ID, or to-assign.',
|
|
505
|
+
' --amount AMOUNT Positive amount in the budget currency, with up to two decimals.',
|
|
506
|
+
' The integer-pence value must be at most 9,007,199,254,740,991.',
|
|
507
|
+
'',
|
|
508
|
+
'Optional inputs:',
|
|
509
|
+
' --period YYYY-MM Defaults to the current Sloth budget period.',
|
|
510
|
+
' --apply Send the movement. Without it, only validate and preview.',
|
|
511
|
+
' --base-url URL Override the API origin.',
|
|
512
|
+
' -h, --help Show this help.',
|
|
513
|
+
...API_ORIGIN_HELP_LINES,
|
|
514
|
+
'',
|
|
515
|
+
'Write behavior:',
|
|
516
|
+
' Without --apply, returns JSON locally without loading credentials or contacting Sloth Money.',
|
|
517
|
+
' With --apply, atomically subtracts from the source and adds to the destination.',
|
|
518
|
+
' Use the reserved ID to-assign to move money to or from To Assign.',
|
|
519
|
+
' The move changes current assigned balances and records budget movement history.',
|
|
520
|
+
' The source category or To Assign may become negative, so choose the source deliberately.',
|
|
521
|
+
' It does not change planned amounts or future budget plans.',
|
|
522
|
+
' Historical periods cannot be changed. Applying requires agent:write.',
|
|
523
|
+
'',
|
|
524
|
+
'Output:',
|
|
525
|
+
' Preview mode returns dryRun, endpoint, method, and the amountPence payload.',
|
|
526
|
+
' Apply mode returns the period, currency, movement, To Assign balance, and affected category balances.',
|
|
527
|
+
'',
|
|
528
|
+
'Examples:',
|
|
529
|
+
' sloth-agent budget move --scope personal --from-category-id activities --to-category-id groceries --amount 52.95',
|
|
530
|
+
' sloth-agent budget move --scope personal --from-category-id activities --to-category-id groceries --amount 52.95 --apply',
|
|
531
|
+
].join('\n');
|
|
532
|
+
}
|
|
460
533
|
export function transactionsHelpText() {
|
|
461
534
|
return [
|
|
462
535
|
'Sloth Agent CLI — transactions',
|
|
@@ -824,6 +897,8 @@ export function commandHelpText(topic) {
|
|
|
824
897
|
'accounts-remove': accountsRemoveHelpText,
|
|
825
898
|
investments: investmentsHelpText,
|
|
826
899
|
budget: budgetHelpText,
|
|
900
|
+
'budget-status': budgetStatusHelpText,
|
|
901
|
+
'budget-move': budgetMoveHelpText,
|
|
827
902
|
'budget-update': budgetUpdateHelpText,
|
|
828
903
|
categories: categoriesHelpText,
|
|
829
904
|
'categories-create': categoriesCreateHelpText,
|
|
@@ -1140,6 +1215,24 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1140
1215
|
});
|
|
1141
1216
|
return 0;
|
|
1142
1217
|
}
|
|
1218
|
+
const budgetMovementPayload = parsed.command === 'budget-move'
|
|
1219
|
+
? {
|
|
1220
|
+
scope: parsed.scope,
|
|
1221
|
+
...(parsed.periodKey === undefined ? {} : { periodKey: parsed.periodKey }),
|
|
1222
|
+
fromCategoryId: parsed.fromCategoryId,
|
|
1223
|
+
toCategoryId: parsed.toCategoryId,
|
|
1224
|
+
amountPence: parsed.amountPence,
|
|
1225
|
+
}
|
|
1226
|
+
: undefined;
|
|
1227
|
+
if (parsed.command === 'budget-move' && !parsed.apply) {
|
|
1228
|
+
writeJson(writeStdout, {
|
|
1229
|
+
dryRun: true,
|
|
1230
|
+
endpoint: `${baseUrl}/api/agent/v1/budget-movements`,
|
|
1231
|
+
method: 'POST',
|
|
1232
|
+
payload: budgetMovementPayload,
|
|
1233
|
+
});
|
|
1234
|
+
return 0;
|
|
1235
|
+
}
|
|
1143
1236
|
const credential = await resolveCredential(environment, baseUrl, getCredentialStore);
|
|
1144
1237
|
token = credential.token;
|
|
1145
1238
|
const headers = requestHeaders(token);
|
|
@@ -1182,6 +1275,17 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1182
1275
|
writeJson(writeStdout, data);
|
|
1183
1276
|
return 0;
|
|
1184
1277
|
}
|
|
1278
|
+
if (parsed.command === 'budget-move') {
|
|
1279
|
+
const response = await fetchImplementation(`${baseUrl}/api/agent/v1/budget-movements`, {
|
|
1280
|
+
method: 'POST',
|
|
1281
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
1282
|
+
body: JSON.stringify(budgetMovementPayload),
|
|
1283
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1284
|
+
});
|
|
1285
|
+
const data = validateBudgetMovementResponse(await parseHttpResponse(response, token), budgetMovementPayload);
|
|
1286
|
+
writeJson(writeStdout, data);
|
|
1287
|
+
return 0;
|
|
1288
|
+
}
|
|
1185
1289
|
if (parsed.command === 'categories-create'
|
|
1186
1290
|
|| parsed.command === 'categories-rename'
|
|
1187
1291
|
|| parsed.command === 'line-items-create'
|
|
@@ -1356,16 +1460,19 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1356
1460
|
writeJson(writeStdout, withListedGoalPriorities(data));
|
|
1357
1461
|
return 0;
|
|
1358
1462
|
}
|
|
1359
|
-
if (parsed.command === 'budget') {
|
|
1463
|
+
if (parsed.command === 'budget' || parsed.command === 'budget-status') {
|
|
1360
1464
|
const query = new URLSearchParams({ scope: parsed.scope });
|
|
1361
|
-
if (parsed.periodKey !== undefined)
|
|
1465
|
+
if (parsed.command === 'budget' && parsed.periodKey !== undefined) {
|
|
1362
1466
|
query.set('periodKey', parsed.periodKey);
|
|
1363
|
-
|
|
1467
|
+
}
|
|
1468
|
+
const response = await fetchImplementation(`${baseUrl}/api/agent/v1/${parsed.command === 'budget' ? 'budgets' : 'budget-status'}?${query.toString()}`, {
|
|
1364
1469
|
method: 'GET',
|
|
1365
|
-
headers
|
|
1470
|
+
headers: parsed.command === 'budget-status'
|
|
1471
|
+
? { ...headers, Prefer: 'wait=45' }
|
|
1472
|
+
: headers,
|
|
1366
1473
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1367
1474
|
});
|
|
1368
|
-
const data = parseApiResponse(
|
|
1475
|
+
const data = parseApiResponse(parsed.command, await parseHttpResponse(response, token));
|
|
1369
1476
|
writeJson(writeStdout, data);
|
|
1370
1477
|
return 0;
|
|
1371
1478
|
}
|
package/dist/contracts.js
CHANGED
|
@@ -227,28 +227,32 @@ function isTransaction(value) {
|
|
|
227
227
|
|| value.incomeSubtype === 'pay'
|
|
228
228
|
|| value.incomeSubtype === 'interest'));
|
|
229
229
|
}
|
|
230
|
+
const REFRESH_STATUSES = new Set(['skipped', 'completed', 'in_progress', 'partial', 'failed']);
|
|
231
|
+
const REFRESH_REASONS = new Set([
|
|
232
|
+
'all_fetched_today',
|
|
233
|
+
'no_api_connections',
|
|
234
|
+
'no_selected_accounts',
|
|
235
|
+
'refreshed',
|
|
236
|
+
'wait_timeout',
|
|
237
|
+
'account_failures',
|
|
238
|
+
'partial_already_attempted',
|
|
239
|
+
'refresh_error',
|
|
240
|
+
]);
|
|
241
|
+
function isRefreshStatus(value) {
|
|
242
|
+
return (isObject(value)
|
|
243
|
+
&& hasOnlyFields(value, ['status', 'reason', 'utcDate'])
|
|
244
|
+
&& typeof value.status === 'string'
|
|
245
|
+
&& REFRESH_STATUSES.has(value.status)
|
|
246
|
+
&& typeof value.reason === 'string'
|
|
247
|
+
&& REFRESH_REASONS.has(value.reason)
|
|
248
|
+
&& isIsoDate(value.utcDate));
|
|
249
|
+
}
|
|
230
250
|
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
251
|
return (isObject(value)
|
|
243
252
|
&& Array.isArray(value.transactions)
|
|
244
253
|
&& value.transactions.every(isTransaction)
|
|
245
254
|
&& (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));
|
|
255
|
+
&& isRefreshStatus(value.refresh));
|
|
252
256
|
}
|
|
253
257
|
function isAssignmentResponse(value) {
|
|
254
258
|
return (isObject(value)
|
|
@@ -375,6 +379,125 @@ function isBudgetResponse(value) {
|
|
|
375
379
|
&& Array.isArray(value.categories)
|
|
376
380
|
&& value.categories.every(isBudgetCategory));
|
|
377
381
|
}
|
|
382
|
+
function isBudgetStatusResponse(value) {
|
|
383
|
+
if (!isObject(value)
|
|
384
|
+
|| !hasOnlyFields(value, [
|
|
385
|
+
'scope',
|
|
386
|
+
'periodKey',
|
|
387
|
+
'periodStatus',
|
|
388
|
+
'currency',
|
|
389
|
+
'effectiveFromPeriodKey',
|
|
390
|
+
'funding',
|
|
391
|
+
'activity',
|
|
392
|
+
'refresh',
|
|
393
|
+
'categories',
|
|
394
|
+
]))
|
|
395
|
+
return false;
|
|
396
|
+
return ((value.scope === 'personal' || value.scope === 'joint')
|
|
397
|
+
&& typeof value.periodKey === 'string'
|
|
398
|
+
&& /^\d{4}-(0[1-9]|1[0-2])$/.test(value.periodKey)
|
|
399
|
+
&& value.periodStatus === 'current'
|
|
400
|
+
&& isCurrency(value.currency)
|
|
401
|
+
&& typeof value.effectiveFromPeriodKey === 'string'
|
|
402
|
+
&& /^\d{4}-(0[1-9]|1[0-2])$/.test(value.effectiveFromPeriodKey)
|
|
403
|
+
&& (value.funding === null
|
|
404
|
+
|| (isObject(value.funding)
|
|
405
|
+
&& hasOnlyFields(value.funding, ['toAssignPence', 'nextPeriodReservePence'])
|
|
406
|
+
&& isSafeInteger(value.funding.toAssignPence)
|
|
407
|
+
&& isSafeInteger(value.funding.nextPeriodReservePence)))
|
|
408
|
+
&& isObject(value.activity)
|
|
409
|
+
&& hasOnlyFields(value.activity, [
|
|
410
|
+
'startDate',
|
|
411
|
+
'endDate',
|
|
412
|
+
'transactionCount',
|
|
413
|
+
'uncategorizedSpentPence',
|
|
414
|
+
'unmappedSpentPence',
|
|
415
|
+
])
|
|
416
|
+
&& isIsoDate(value.activity.startDate)
|
|
417
|
+
&& isIsoDate(value.activity.endDate)
|
|
418
|
+
&& value.activity.startDate <= value.activity.endDate
|
|
419
|
+
&& isNonnegativeSafeInteger(value.activity.transactionCount)
|
|
420
|
+
&& isSafeInteger(value.activity.uncategorizedSpentPence)
|
|
421
|
+
&& isSafeInteger(value.activity.unmappedSpentPence)
|
|
422
|
+
&& isRefreshStatus(value.refresh)
|
|
423
|
+
&& Array.isArray(value.categories)
|
|
424
|
+
&& value.categories.every((category) => (isObject(category)
|
|
425
|
+
&& hasOnlyFields(category, [
|
|
426
|
+
'id',
|
|
427
|
+
'name',
|
|
428
|
+
'plannedPence',
|
|
429
|
+
'assignedPence',
|
|
430
|
+
'spentPence',
|
|
431
|
+
'availablePence',
|
|
432
|
+
])
|
|
433
|
+
&& typeof category.id === 'string'
|
|
434
|
+
&& category.id.trim().length > 0
|
|
435
|
+
&& typeof category.name === 'string'
|
|
436
|
+
&& category.name.trim().length > 0
|
|
437
|
+
&& isNonnegativeSafeInteger(category.plannedPence)
|
|
438
|
+
&& isSafeInteger(category.assignedPence)
|
|
439
|
+
&& isSafeInteger(category.spentPence)
|
|
440
|
+
&& isSafeInteger(category.availablePence)
|
|
441
|
+
&& Number.isSafeInteger(category.assignedPence - category.spentPence)
|
|
442
|
+
&& category.availablePence === category.assignedPence - category.spentPence)));
|
|
443
|
+
}
|
|
444
|
+
function isBudgetMovementResponse(value) {
|
|
445
|
+
return (isObject(value)
|
|
446
|
+
&& hasOnlyFields(value, [
|
|
447
|
+
'moved',
|
|
448
|
+
'scope',
|
|
449
|
+
'periodKey',
|
|
450
|
+
'currency',
|
|
451
|
+
'fromCategoryId',
|
|
452
|
+
'toCategoryId',
|
|
453
|
+
'amountPence',
|
|
454
|
+
'toAssignPence',
|
|
455
|
+
'categoryBalances',
|
|
456
|
+
])
|
|
457
|
+
&& value.moved === true
|
|
458
|
+
&& (value.scope === 'personal' || value.scope === 'joint')
|
|
459
|
+
&& typeof value.periodKey === 'string'
|
|
460
|
+
&& /^\d{4}-(0[1-9]|1[0-2])$/.test(value.periodKey)
|
|
461
|
+
&& isCurrency(value.currency)
|
|
462
|
+
&& typeof value.fromCategoryId === 'string'
|
|
463
|
+
&& value.fromCategoryId.trim().length > 0
|
|
464
|
+
&& typeof value.toCategoryId === 'string'
|
|
465
|
+
&& value.toCategoryId.trim().length > 0
|
|
466
|
+
&& value.fromCategoryId !== value.toCategoryId
|
|
467
|
+
&& isNonnegativeSafeInteger(value.amountPence)
|
|
468
|
+
&& value.amountPence > 0
|
|
469
|
+
&& isSafeInteger(value.toAssignPence)
|
|
470
|
+
&& Array.isArray(value.categoryBalances)
|
|
471
|
+
&& value.categoryBalances.length >= 1
|
|
472
|
+
&& value.categoryBalances.length <= 2
|
|
473
|
+
&& value.categoryBalances.every((balance) => (isObject(balance)
|
|
474
|
+
&& hasOnlyFields(balance, ['categoryId', 'assignedPence'])
|
|
475
|
+
&& typeof balance.categoryId === 'string'
|
|
476
|
+
&& balance.categoryId.trim().length > 0
|
|
477
|
+
&& isSafeInteger(balance.assignedPence))));
|
|
478
|
+
}
|
|
479
|
+
export function validateBudgetMovementResponse(value, expected) {
|
|
480
|
+
if (!isBudgetMovementResponse(value)) {
|
|
481
|
+
throw new ApiError('Invalid budget-move response from the Agent API');
|
|
482
|
+
}
|
|
483
|
+
const expectedCategoryIds = [expected.fromCategoryId, expected.toCategoryId]
|
|
484
|
+
.filter(categoryId => categoryId !== 'to-assign')
|
|
485
|
+
.sort();
|
|
486
|
+
const actualCategoryIds = value.categoryBalances
|
|
487
|
+
.map(balance => balance.categoryId)
|
|
488
|
+
.sort();
|
|
489
|
+
const matchesRequest = value.scope === expected.scope
|
|
490
|
+
&& (expected.periodKey === undefined || value.periodKey === expected.periodKey)
|
|
491
|
+
&& value.fromCategoryId === expected.fromCategoryId
|
|
492
|
+
&& value.toCategoryId === expected.toCategoryId
|
|
493
|
+
&& value.amountPence === expected.amountPence;
|
|
494
|
+
const matchesAffectedCategories = actualCategoryIds.length === expectedCategoryIds.length
|
|
495
|
+
&& actualCategoryIds.every((categoryId, index) => categoryId === expectedCategoryIds[index]);
|
|
496
|
+
if (!matchesRequest || !matchesAffectedCategories) {
|
|
497
|
+
throw new ApiError('Invalid budget-move response from the Agent API');
|
|
498
|
+
}
|
|
499
|
+
return value;
|
|
500
|
+
}
|
|
378
501
|
function isNullableNonEmptyString(value) {
|
|
379
502
|
return value === null || (typeof value === 'string'
|
|
380
503
|
&& value.length > 0
|
|
@@ -511,23 +634,27 @@ export function parseApiResponse(command, value) {
|
|
|
511
634
|
? isInvestmentsResponse(value)
|
|
512
635
|
: command === 'budget' || command === 'budget-update'
|
|
513
636
|
? isBudgetResponse(value)
|
|
514
|
-
: command === '
|
|
515
|
-
?
|
|
516
|
-
: command === '
|
|
517
|
-
?
|
|
518
|
-
: command === '
|
|
519
|
-
?
|
|
520
|
-
: command === '
|
|
521
|
-
?
|
|
522
|
-
: command === '
|
|
523
|
-
?
|
|
524
|
-
: command === '
|
|
525
|
-
?
|
|
526
|
-
: command === '
|
|
527
|
-
?
|
|
528
|
-
: command === '
|
|
529
|
-
?
|
|
530
|
-
:
|
|
637
|
+
: command === 'budget-status'
|
|
638
|
+
? isBudgetStatusResponse(value)
|
|
639
|
+
: command === 'budget-move'
|
|
640
|
+
? isBudgetMovementResponse(value)
|
|
641
|
+
: command === 'categories'
|
|
642
|
+
? isCategoryResponse(value)
|
|
643
|
+
: command === 'categories-create' || command === 'categories-rename'
|
|
644
|
+
? isCategoryMutationResponse(value)
|
|
645
|
+
: command === 'line-items-create' || command === 'line-items-rename'
|
|
646
|
+
? isLineItemMutationResponse(value)
|
|
647
|
+
: command === 'transactions'
|
|
648
|
+
? isTransactionsResponse(value)
|
|
649
|
+
: command === 'assign'
|
|
650
|
+
? isAssignmentResponse(value)
|
|
651
|
+
: command === 'ask-partner'
|
|
652
|
+
? isPartnerResponse(value)
|
|
653
|
+
: command === 'goals-list'
|
|
654
|
+
? isGoalsResponse(value)
|
|
655
|
+
: command === 'goals-delete'
|
|
656
|
+
? isGoalDeleteResponse(value)
|
|
657
|
+
: isGoalMutationResponse(value);
|
|
531
658
|
if (!valid) {
|
|
532
659
|
const label = command === 'assign' ? 'assignment' : command;
|
|
533
660
|
throw new ApiError(`Invalid ${label} response from the Agent API`);
|