@slothmoney/agent-cli 0.2.0 → 0.3.1

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/dist/contracts.js CHANGED
@@ -2,6 +2,12 @@ import { ApiError, UsageError, } from './errors.js';
2
2
  function isObject(value) {
3
3
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
4
4
  }
5
+ function isIsoDate(value) {
6
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value))
7
+ return false;
8
+ const parsed = new Date(`${value}T00:00:00.000Z`);
9
+ return !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value;
10
+ }
5
11
  function requireObject(value, label) {
6
12
  if (!isObject(value))
7
13
  throw new UsageError(`${label} must be an object`);
@@ -38,12 +44,18 @@ function validateAssignment(value, index) {
38
44
  const assignment = requireObject(value, label);
39
45
  rejectUnknownFields(assignment, new Set([
40
46
  'transactionRef',
47
+ 'assignmentScope',
41
48
  'categoryId',
42
49
  'lineItemId',
43
50
  'categorySplits',
44
51
  'incomeSubtype',
45
52
  ]), label);
46
53
  requireString(assignment.transactionRef, `${label}.transactionRef`);
54
+ if (assignment.assignmentScope !== undefined
55
+ && assignment.assignmentScope !== 'personal'
56
+ && assignment.assignmentScope !== 'joint') {
57
+ throw new UsageError(`${label}.assignmentScope must be personal or joint`);
58
+ }
47
59
  if (assignment.categoryId !== undefined && assignment.categoryId !== null) {
48
60
  requireString(assignment.categoryId, `${label}.categoryId`);
49
61
  }
@@ -74,6 +86,9 @@ function validateAssignment(value, index) {
74
86
  }
75
87
  return {
76
88
  transactionRef: assignment.transactionRef,
89
+ ...(assignment.assignmentScope !== undefined
90
+ ? { assignmentScope: assignment.assignmentScope }
91
+ : {}),
77
92
  ...(assignment.categoryId !== undefined ? { categoryId: assignment.categoryId } : {}),
78
93
  ...(assignment.lineItemId !== undefined ? { lineItemId: assignment.lineItemId } : {}),
79
94
  ...(categorySplits !== undefined ? { categorySplits } : {}),
@@ -130,25 +145,72 @@ function isTransaction(value) {
130
145
  && (value.categoryId === null || typeof value.categoryId === 'string')
131
146
  && (value.lineItemId === null || typeof value.lineItemId === 'string')
132
147
  && Array.isArray(value.categorySplits)
148
+ && (value.jointBudgetContribution === null
149
+ || (isObject(value.jointBudgetContribution)
150
+ && typeof value.jointBudgetContribution.eligible === 'boolean'
151
+ && typeof value.jointBudgetContribution.included === 'boolean'
152
+ && Number.isInteger(value.jointBudgetContribution.amountPence)
153
+ && Number(value.jointBudgetContribution.amountPence) > 0
154
+ && (value.jointBudgetContribution.categoryId === null
155
+ || typeof value.jointBudgetContribution.categoryId === 'string')
156
+ && (value.jointBudgetContribution.lineItemId === null
157
+ || typeof value.jointBudgetContribution.lineItemId === 'string')
158
+ && Array.isArray(value.jointBudgetContribution.categorySplits)
159
+ && (value.jointBudgetContribution.incomeSubtype === null
160
+ || value.jointBudgetContribution.incomeSubtype === 'pay'
161
+ || value.jointBudgetContribution.incomeSubtype === 'interest')))
133
162
  && (value.incomeSubtype === null
134
163
  || value.incomeSubtype === 'pay'
135
164
  || value.incomeSubtype === 'interest'));
136
165
  }
137
166
  function isTransactionsResponse(value) {
167
+ const validStatuses = new Set(['skipped', 'completed', 'in_progress', 'partial', 'failed']);
168
+ const validReasons = new Set([
169
+ 'all_fetched_today',
170
+ 'no_api_connections',
171
+ 'no_selected_accounts',
172
+ 'refreshed',
173
+ 'wait_timeout',
174
+ 'account_failures',
175
+ 'refresh_error',
176
+ ]);
177
+ const refresh = isObject(value) ? value.refresh : undefined;
138
178
  return (isObject(value)
139
179
  && Array.isArray(value.transactions)
140
180
  && value.transactions.every(isTransaction)
141
- && (value.nextCursor === null || typeof value.nextCursor === 'string'));
181
+ && (value.nextCursor === null || typeof value.nextCursor === 'string')
182
+ && isObject(refresh)
183
+ && typeof refresh.status === 'string'
184
+ && validStatuses.has(refresh.status)
185
+ && typeof refresh.reason === 'string'
186
+ && validReasons.has(refresh.reason)
187
+ && isIsoDate(refresh.utcDate));
142
188
  }
143
189
  function isAssignmentResponse(value) {
144
190
  return (isObject(value)
145
191
  && Array.isArray(value.succeeded)
146
- && value.succeeded.every((item) => isObject(item) && typeof item.transactionRef === 'string')
192
+ && value.succeeded.every((item) => (isObject(item)
193
+ && typeof item.transactionRef === 'string'
194
+ && (item.assignmentScope === 'personal' || item.assignmentScope === 'joint')))
147
195
  && Array.isArray(value.failed)
148
196
  && value.failed.every((item) => (isObject(item)
149
197
  && typeof item.error === 'string'
150
198
  && (item.transactionRef === undefined || typeof item.transactionRef === 'string'))));
151
199
  }
200
+ export function validateJointBudgetSettingsResponse(value) {
201
+ if (!isObject(value)
202
+ || Object.keys(value).some((key) => !new Set([
203
+ 'includeSharedPersonalTransactions',
204
+ 'updatedAt',
205
+ 'updatedBy',
206
+ ]).has(key))
207
+ || typeof value.includeSharedPersonalTransactions !== 'boolean'
208
+ || (value.updatedAt !== null && !isIsoDateTime(value.updatedAt))
209
+ || (value.updatedBy !== null && typeof value.updatedBy !== 'string')) {
210
+ throw new ApiError('Invalid joint budget settings response from the Agent API');
211
+ }
212
+ return value;
213
+ }
152
214
  function isHttpUrl(value) {
153
215
  if (typeof value !== 'string')
154
216
  return false;
@@ -175,6 +237,55 @@ function isPartnerResponse(value) {
175
237
  && isIsoDateTime(value.expiresAt)
176
238
  && value.status === 'open');
177
239
  }
240
+ function hasOnlyFields(value, fields) {
241
+ const allowed = new Set(fields);
242
+ return Object.keys(value).every((key) => allowed.has(key));
243
+ }
244
+ function isGoal(value) {
245
+ return (isObject(value)
246
+ && hasOnlyFields(value, [
247
+ 'id',
248
+ 'name',
249
+ 'targetAmount',
250
+ 'targetMonthKey',
251
+ 'isAchieved',
252
+ 'sharedWithPartner',
253
+ ])
254
+ && typeof value.id === 'string'
255
+ && value.id.trim().length > 0
256
+ && typeof value.name === 'string'
257
+ && value.name.trim().length > 0
258
+ && (value.targetAmount === null
259
+ || (typeof value.targetAmount === 'number' && Number.isFinite(value.targetAmount)))
260
+ && (value.targetMonthKey === null
261
+ || (typeof value.targetMonthKey === 'string'
262
+ && /^\d{4}-(0[1-9]|1[0-2])$/.test(value.targetMonthKey)))
263
+ && typeof value.isAchieved === 'boolean'
264
+ && typeof value.sharedWithPartner === 'boolean');
265
+ }
266
+ function isCurrency(value) {
267
+ return typeof value === 'string' && /^[A-Z]{3}$/.test(value);
268
+ }
269
+ function isGoalsResponse(value) {
270
+ return (isObject(value)
271
+ && hasOnlyFields(value, ['currency', 'goals'])
272
+ && isCurrency(value.currency)
273
+ && Array.isArray(value.goals)
274
+ && value.goals.every(isGoal));
275
+ }
276
+ function isGoalMutationResponse(value) {
277
+ return (isObject(value)
278
+ && hasOnlyFields(value, ['currency', 'goal'])
279
+ && isCurrency(value.currency)
280
+ && isGoal(value.goal));
281
+ }
282
+ function isGoalDeleteResponse(value) {
283
+ return (isObject(value)
284
+ && hasOnlyFields(value, ['deleted', 'deletedGoalId'])
285
+ && value.deleted === true
286
+ && typeof value.deletedGoalId === 'string'
287
+ && value.deletedGoalId.trim().length > 0);
288
+ }
178
289
  export function parseApiResponse(command, value) {
179
290
  const valid = command === 'categories'
180
291
  ? isCategoryResponse(value)
@@ -182,7 +293,13 @@ export function parseApiResponse(command, value) {
182
293
  ? isTransactionsResponse(value)
183
294
  : command === 'assign'
184
295
  ? isAssignmentResponse(value)
185
- : isPartnerResponse(value);
296
+ : command === 'ask-partner'
297
+ ? isPartnerResponse(value)
298
+ : command === 'goals-list'
299
+ ? isGoalsResponse(value)
300
+ : command === 'goals-delete'
301
+ ? isGoalDeleteResponse(value)
302
+ : isGoalMutationResponse(value);
186
303
  if (!valid) {
187
304
  const label = command === 'assign' ? 'assignment' : command;
188
305
  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.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {