@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 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.10.0 -- sloth-agent --help
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 parseGoalAmount(value, name) {
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 === '--category-id' ? 'category' : 'line-item';
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 update = args[0] === 'update';
359
- if (update)
360
- args.shift();
361
- const { values, apply } = parseNamedOptions(args, update ? 'budget update' : 'budget', new Set(update ? ['--scope', '--period', '--input'] : ['--scope', '--period']));
362
- if (!update && apply)
363
- throw new UsageError('Unknown budget option: --apply');
364
- const scope = requiredOption(values, '--scope', update ? 'budget update' : 'budget');
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, parseGoalAmount(value, option), option);
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, parseGoalAmount(value, option), option);
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
- return subcommand === 'update' ? 'budget-update' : 'budget';
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.10.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
- const response = await fetchImplementation(`${baseUrl}/api/agent/v1/budgets?${query.toString()}`, {
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('budget', await parseHttpResponse(response, token));
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
- && isObject(refresh)
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 === 'categories'
515
- ? isCategoryResponse(value)
516
- : command === 'categories-create' || command === 'categories-rename'
517
- ? isCategoryMutationResponse(value)
518
- : command === 'line-items-create' || command === 'line-items-rename'
519
- ? isLineItemMutationResponse(value)
520
- : command === 'transactions'
521
- ? isTransactionsResponse(value)
522
- : command === 'assign'
523
- ? isAssignmentResponse(value)
524
- : command === 'ask-partner'
525
- ? isPartnerResponse(value)
526
- : command === 'goals-list'
527
- ? isGoalsResponse(value)
528
- : command === 'goals-delete'
529
- ? isGoalDeleteResponse(value)
530
- : isGoalMutationResponse(value);
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`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slothmoney/agent-cli",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {