@stephendolan/ynab-cli 1.0.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/LICENSE +21 -0
- package/README.md +202 -0
- package/dist/cli.js +1202 -0
- package/dist/cli.js.map +1 -0
- package/package.json +70 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command as Command11 } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/lib/utils.ts
|
|
7
|
+
import { format, parseISO } from "date-fns";
|
|
8
|
+
function milliunitsToAmount(milliunits) {
|
|
9
|
+
return milliunits / 1e3;
|
|
10
|
+
}
|
|
11
|
+
function amountToMilliunits(amount) {
|
|
12
|
+
return Math.round(amount * 1e3);
|
|
13
|
+
}
|
|
14
|
+
function isInteractive() {
|
|
15
|
+
return process.stdin.isTTY === true;
|
|
16
|
+
}
|
|
17
|
+
function convertMilliunitsToAmounts(data) {
|
|
18
|
+
if (data === null || data === void 0) {
|
|
19
|
+
return data;
|
|
20
|
+
}
|
|
21
|
+
if (Array.isArray(data)) {
|
|
22
|
+
return data.map((item) => convertMilliunitsToAmounts(item));
|
|
23
|
+
}
|
|
24
|
+
if (typeof data === "object") {
|
|
25
|
+
const converted = {};
|
|
26
|
+
for (const [key, value] of Object.entries(data)) {
|
|
27
|
+
if (isAmountField(key) && typeof value === "number") {
|
|
28
|
+
converted[key] = milliunitsToAmount(value);
|
|
29
|
+
} else {
|
|
30
|
+
converted[key] = convertMilliunitsToAmounts(value);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return converted;
|
|
34
|
+
}
|
|
35
|
+
return data;
|
|
36
|
+
}
|
|
37
|
+
function isAmountField(fieldName) {
|
|
38
|
+
const amountFields = [
|
|
39
|
+
"amount",
|
|
40
|
+
"balance",
|
|
41
|
+
"cleared_balance",
|
|
42
|
+
"uncleared_balance",
|
|
43
|
+
"budgeted",
|
|
44
|
+
"activity",
|
|
45
|
+
"available",
|
|
46
|
+
"goal_target"
|
|
47
|
+
];
|
|
48
|
+
return amountFields.includes(fieldName) || fieldName.endsWith("_amount");
|
|
49
|
+
}
|
|
50
|
+
function parseApprovedFilter(value) {
|
|
51
|
+
const normalized = value.toLowerCase();
|
|
52
|
+
if (normalized !== "true" && normalized !== "false") {
|
|
53
|
+
throw new Error(`--approved must be 'true' or 'false', got '${value}'`);
|
|
54
|
+
}
|
|
55
|
+
return normalized === "true";
|
|
56
|
+
}
|
|
57
|
+
function parseStatusFilter(value) {
|
|
58
|
+
const statuses = value.split(",").map((s) => s.trim().toLowerCase());
|
|
59
|
+
const validStatuses = ["cleared", "uncleared", "reconciled"];
|
|
60
|
+
for (const status of statuses) {
|
|
61
|
+
if (!validStatuses.includes(status)) {
|
|
62
|
+
throw new Error(`Invalid status '${status}'. Must be one of: ${validStatuses.join(", ")}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return statuses;
|
|
66
|
+
}
|
|
67
|
+
function applyTransactionFilters(transactions, filters) {
|
|
68
|
+
let filtered = transactions;
|
|
69
|
+
if (filters.until) {
|
|
70
|
+
filtered = filtered.filter((t) => t.date <= filters.until);
|
|
71
|
+
}
|
|
72
|
+
if (filters.approved !== void 0) {
|
|
73
|
+
const approvedValue = parseApprovedFilter(filters.approved);
|
|
74
|
+
filtered = filtered.filter((t) => t.approved === approvedValue);
|
|
75
|
+
}
|
|
76
|
+
if (filters.status) {
|
|
77
|
+
const statuses = parseStatusFilter(filters.status);
|
|
78
|
+
filtered = filtered.filter((t) => statuses.includes(t.cleared.toLowerCase()));
|
|
79
|
+
}
|
|
80
|
+
if (filters.minAmount !== void 0) {
|
|
81
|
+
const minMilliunits = amountToMilliunits(filters.minAmount);
|
|
82
|
+
filtered = filtered.filter((t) => t.amount >= minMilliunits);
|
|
83
|
+
}
|
|
84
|
+
if (filters.maxAmount !== void 0) {
|
|
85
|
+
const maxMilliunits = amountToMilliunits(filters.maxAmount);
|
|
86
|
+
filtered = filtered.filter((t) => t.amount <= maxMilliunits);
|
|
87
|
+
}
|
|
88
|
+
return filtered;
|
|
89
|
+
}
|
|
90
|
+
function applyFieldSelection(items, fields) {
|
|
91
|
+
if (!fields) return items;
|
|
92
|
+
const fieldList = fields.split(",").map((f) => f.trim());
|
|
93
|
+
return items.map((item) => {
|
|
94
|
+
const filtered = {};
|
|
95
|
+
fieldList.forEach((field) => {
|
|
96
|
+
if (field in item) {
|
|
97
|
+
filtered[field] = item[field];
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
return filtered;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/lib/output.ts
|
|
105
|
+
var globalOutputOptions = {};
|
|
106
|
+
function setOutputOptions(options) {
|
|
107
|
+
globalOutputOptions = options;
|
|
108
|
+
}
|
|
109
|
+
function outputJson(data, options = {}) {
|
|
110
|
+
const convertedData = convertMilliunitsToAmounts(data);
|
|
111
|
+
const mergedOptions = { ...globalOutputOptions, ...options };
|
|
112
|
+
const jsonString = mergedOptions.compact ? JSON.stringify(convertedData) : JSON.stringify(convertedData, null, 2);
|
|
113
|
+
console.log(jsonString);
|
|
114
|
+
}
|
|
115
|
+
function outputSuccess(data, options = {}) {
|
|
116
|
+
outputJson(data, options);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/commands/auth.ts
|
|
120
|
+
import { Command } from "commander";
|
|
121
|
+
|
|
122
|
+
// src/lib/auth.ts
|
|
123
|
+
import keytar from "keytar";
|
|
124
|
+
|
|
125
|
+
// src/lib/config.ts
|
|
126
|
+
import Conf from "conf";
|
|
127
|
+
var ConfigManager = class {
|
|
128
|
+
conf;
|
|
129
|
+
constructor() {
|
|
130
|
+
this.conf = new Conf({
|
|
131
|
+
projectName: "ynab-cli",
|
|
132
|
+
schema: {
|
|
133
|
+
defaultBudget: { type: "string" },
|
|
134
|
+
version: { type: "string", default: "1.0.0" }
|
|
135
|
+
},
|
|
136
|
+
defaults: { version: "1.0.0" }
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
getDefaultBudget() {
|
|
140
|
+
return this.conf.get("defaultBudget");
|
|
141
|
+
}
|
|
142
|
+
setDefaultBudget(budgetId) {
|
|
143
|
+
this.conf.set("defaultBudget", budgetId);
|
|
144
|
+
}
|
|
145
|
+
clearDefaultBudget() {
|
|
146
|
+
this.conf.delete("defaultBudget");
|
|
147
|
+
}
|
|
148
|
+
clear() {
|
|
149
|
+
this.conf.clear();
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
var config = new ConfigManager();
|
|
153
|
+
|
|
154
|
+
// src/lib/auth.ts
|
|
155
|
+
var SERVICE_NAME = "ynab-cli";
|
|
156
|
+
var ACCOUNT_NAME = "access-token";
|
|
157
|
+
var AuthManager = class {
|
|
158
|
+
async getAccessToken() {
|
|
159
|
+
return keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
|
|
160
|
+
}
|
|
161
|
+
async setAccessToken(token) {
|
|
162
|
+
await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, token);
|
|
163
|
+
}
|
|
164
|
+
async deleteAccessToken() {
|
|
165
|
+
return keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
|
|
166
|
+
}
|
|
167
|
+
async isAuthenticated() {
|
|
168
|
+
return await this.getAccessToken() !== null;
|
|
169
|
+
}
|
|
170
|
+
async logout() {
|
|
171
|
+
await this.deleteAccessToken();
|
|
172
|
+
config.clearDefaultBudget();
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
var auth = new AuthManager();
|
|
176
|
+
|
|
177
|
+
// src/lib/prompts.ts
|
|
178
|
+
import inquirer from "inquirer";
|
|
179
|
+
async function promptForAccessToken() {
|
|
180
|
+
const { token } = await inquirer.prompt([
|
|
181
|
+
{
|
|
182
|
+
type: "password",
|
|
183
|
+
name: "token",
|
|
184
|
+
message: "Enter your YNAB Personal Access Token:",
|
|
185
|
+
validate: (input) => !!input.trim() || "Access token is required"
|
|
186
|
+
}
|
|
187
|
+
]);
|
|
188
|
+
return token;
|
|
189
|
+
}
|
|
190
|
+
async function promptForConfirmation(message) {
|
|
191
|
+
const { confirmed } = await inquirer.prompt([
|
|
192
|
+
{
|
|
193
|
+
type: "confirm",
|
|
194
|
+
name: "confirmed",
|
|
195
|
+
message,
|
|
196
|
+
default: false
|
|
197
|
+
}
|
|
198
|
+
]);
|
|
199
|
+
return confirmed;
|
|
200
|
+
}
|
|
201
|
+
async function promptForTransaction() {
|
|
202
|
+
const answers = await inquirer.prompt([
|
|
203
|
+
{
|
|
204
|
+
type: "input",
|
|
205
|
+
name: "date",
|
|
206
|
+
message: "Date (YYYY-MM-DD):",
|
|
207
|
+
default: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
type: "input",
|
|
211
|
+
name: "amount",
|
|
212
|
+
message: "Amount (in currency units, e.g., 10.50):",
|
|
213
|
+
validate: (input) => !isNaN(parseFloat(input)) || "Amount must be a valid number"
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
type: "input",
|
|
217
|
+
name: "payee_name",
|
|
218
|
+
message: "Payee name (optional):"
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
type: "input",
|
|
222
|
+
name: "memo",
|
|
223
|
+
message: "Memo (optional):"
|
|
224
|
+
}
|
|
225
|
+
]);
|
|
226
|
+
return {
|
|
227
|
+
date: answers.date,
|
|
228
|
+
amount: amountToMilliunits(parseFloat(answers.amount)),
|
|
229
|
+
payee_name: answers.payee_name || void 0,
|
|
230
|
+
memo: answers.memo || void 0
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/lib/api-client.ts
|
|
235
|
+
import * as ynab from "ynab";
|
|
236
|
+
|
|
237
|
+
// src/lib/errors.ts
|
|
238
|
+
var YnabCliError = class extends Error {
|
|
239
|
+
constructor(message, statusCode) {
|
|
240
|
+
super(message);
|
|
241
|
+
this.statusCode = statusCode;
|
|
242
|
+
this.name = "YnabCliError";
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
var ERROR_STATUS_CODES = {
|
|
246
|
+
bad_request: 400,
|
|
247
|
+
not_authorized: 401,
|
|
248
|
+
subscription_lapsed: 403,
|
|
249
|
+
trial_expired: 403,
|
|
250
|
+
unauthorized_scope: 403,
|
|
251
|
+
data_limit_reached: 403,
|
|
252
|
+
not_found: 404,
|
|
253
|
+
resource_not_found: 404,
|
|
254
|
+
conflict: 409,
|
|
255
|
+
too_many_requests: 429,
|
|
256
|
+
internal_server_error: 500,
|
|
257
|
+
service_unavailable: 503
|
|
258
|
+
};
|
|
259
|
+
function sanitizeErrorMessage(message) {
|
|
260
|
+
const sensitivePatterns = [
|
|
261
|
+
/Bearer\s+[\w\-._~+/]+=*/gi,
|
|
262
|
+
/token[=:]\s*[\w\-._~+/]+=*/gi,
|
|
263
|
+
/api[_-]?key[=:]\s*[\w\-._~+/]+=*/gi,
|
|
264
|
+
/authorization:\s*bearer\s+[\w\-._~+/]+=*/gi
|
|
265
|
+
];
|
|
266
|
+
let sanitized = message;
|
|
267
|
+
for (const pattern of sensitivePatterns) {
|
|
268
|
+
sanitized = sanitized.replace(pattern, "[REDACTED]");
|
|
269
|
+
}
|
|
270
|
+
return sanitized.length > 500 ? sanitized.substring(0, 500) + "..." : sanitized;
|
|
271
|
+
}
|
|
272
|
+
function sanitizeApiError(error) {
|
|
273
|
+
const detail = sanitizeErrorMessage(
|
|
274
|
+
String(error.detail || error.message || "An error occurred")
|
|
275
|
+
);
|
|
276
|
+
return {
|
|
277
|
+
name: error.name || "api_error",
|
|
278
|
+
detail,
|
|
279
|
+
id: error.id
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function formatErrorResponse(name, detail, statusCode) {
|
|
283
|
+
outputJson({ error: { name, detail, statusCode } });
|
|
284
|
+
process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
function handleYnabError(error) {
|
|
287
|
+
if (error.error) {
|
|
288
|
+
const ynabError = sanitizeApiError(error.error);
|
|
289
|
+
formatErrorResponse(
|
|
290
|
+
ynabError.name,
|
|
291
|
+
ynabError.detail,
|
|
292
|
+
ERROR_STATUS_CODES[ynabError.name] || 500
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (error instanceof YnabCliError) {
|
|
296
|
+
const sanitized2 = sanitizeErrorMessage(error.message);
|
|
297
|
+
formatErrorResponse("cli_error", sanitized2, error.statusCode || 1);
|
|
298
|
+
}
|
|
299
|
+
const sanitized = sanitizeErrorMessage(error.message || "An unexpected error occurred");
|
|
300
|
+
formatErrorResponse("unknown_error", sanitized, 1);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// src/lib/api-client.ts
|
|
304
|
+
import dotenv from "dotenv";
|
|
305
|
+
dotenv.config();
|
|
306
|
+
var YnabClient = class {
|
|
307
|
+
api = null;
|
|
308
|
+
hasShownEnvVarWarning = false;
|
|
309
|
+
async getApi() {
|
|
310
|
+
if (this.api) {
|
|
311
|
+
return this.api;
|
|
312
|
+
}
|
|
313
|
+
const keychainToken = await auth.getAccessToken();
|
|
314
|
+
const accessToken = keychainToken || process.env.YNAB_API_KEY || null;
|
|
315
|
+
if (!accessToken) {
|
|
316
|
+
throw new YnabCliError(
|
|
317
|
+
"Not authenticated. Please run: ynab auth login or set YNAB_API_KEY environment variable",
|
|
318
|
+
401
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
if (!keychainToken && process.env.YNAB_API_KEY && !this.hasShownEnvVarWarning) {
|
|
322
|
+
console.warn(
|
|
323
|
+
"\x1B[33m\u26A0\uFE0F WARNING: Using YNAB_API_KEY environment variable.\nEnvironment variables may be visible to other processes.\nFor better security, use: ynab auth login\x1B[0m\n"
|
|
324
|
+
);
|
|
325
|
+
this.hasShownEnvVarWarning = true;
|
|
326
|
+
}
|
|
327
|
+
this.api = new ynab.API(accessToken);
|
|
328
|
+
return this.api;
|
|
329
|
+
}
|
|
330
|
+
async getBudgetId(budgetIdOrDefault) {
|
|
331
|
+
const budgetId = budgetIdOrDefault || config.getDefaultBudget() || process.env.YNAB_BUDGET_ID;
|
|
332
|
+
if (!budgetId) {
|
|
333
|
+
throw new YnabCliError(
|
|
334
|
+
'No budget specified. Use --budget flag, set default with "ynab budgets set-default", or set YNAB_BUDGET_ID environment variable',
|
|
335
|
+
400
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return budgetId;
|
|
339
|
+
}
|
|
340
|
+
async withErrorHandling(fn) {
|
|
341
|
+
try {
|
|
342
|
+
return await fn();
|
|
343
|
+
} catch (error) {
|
|
344
|
+
handleYnabError(error);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async getUser() {
|
|
348
|
+
return this.withErrorHandling(async () => {
|
|
349
|
+
const api = await this.getApi();
|
|
350
|
+
const response = await api.user.getUser();
|
|
351
|
+
return response.data.user;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
async getBudgets(includeAccounts = false) {
|
|
355
|
+
return this.withErrorHandling(async () => {
|
|
356
|
+
const api = await this.getApi();
|
|
357
|
+
const response = await api.budgets.getBudgets(includeAccounts);
|
|
358
|
+
return {
|
|
359
|
+
budgets: response.data.budgets,
|
|
360
|
+
server_knowledge: 0
|
|
361
|
+
};
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
async getBudget(budgetId, lastKnowledgeOfServer) {
|
|
365
|
+
return this.withErrorHandling(async () => {
|
|
366
|
+
const api = await this.getApi();
|
|
367
|
+
const id = await this.getBudgetId(budgetId);
|
|
368
|
+
const response = await api.budgets.getBudgetById(id, lastKnowledgeOfServer);
|
|
369
|
+
return {
|
|
370
|
+
budget: response.data.budget,
|
|
371
|
+
server_knowledge: response.data.server_knowledge
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
async getBudgetSettings(budgetId) {
|
|
376
|
+
return this.withErrorHandling(async () => {
|
|
377
|
+
const api = await this.getApi();
|
|
378
|
+
const id = await this.getBudgetId(budgetId);
|
|
379
|
+
const response = await api.budgets.getBudgetSettingsById(id);
|
|
380
|
+
return response.data.settings;
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
async getAccounts(budgetId, lastKnowledgeOfServer) {
|
|
384
|
+
return this.withErrorHandling(async () => {
|
|
385
|
+
const api = await this.getApi();
|
|
386
|
+
const id = await this.getBudgetId(budgetId);
|
|
387
|
+
const response = await api.accounts.getAccounts(id, lastKnowledgeOfServer);
|
|
388
|
+
return {
|
|
389
|
+
accounts: response.data.accounts,
|
|
390
|
+
server_knowledge: response.data.server_knowledge
|
|
391
|
+
};
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
async getAccount(accountId, budgetId) {
|
|
395
|
+
return this.withErrorHandling(async () => {
|
|
396
|
+
const api = await this.getApi();
|
|
397
|
+
const id = await this.getBudgetId(budgetId);
|
|
398
|
+
const response = await api.accounts.getAccountById(id, accountId);
|
|
399
|
+
return response.data.account;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
async createAccount(accountData, budgetId) {
|
|
403
|
+
return this.withErrorHandling(async () => {
|
|
404
|
+
const api = await this.getApi();
|
|
405
|
+
const id = await this.getBudgetId(budgetId);
|
|
406
|
+
const response = await api.accounts.createAccount(id, accountData);
|
|
407
|
+
return response.data.account;
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
async getCategories(budgetId, lastKnowledgeOfServer) {
|
|
411
|
+
return this.withErrorHandling(async () => {
|
|
412
|
+
const api = await this.getApi();
|
|
413
|
+
const id = await this.getBudgetId(budgetId);
|
|
414
|
+
const response = await api.categories.getCategories(id, lastKnowledgeOfServer);
|
|
415
|
+
return {
|
|
416
|
+
category_groups: response.data.category_groups,
|
|
417
|
+
server_knowledge: response.data.server_knowledge
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
async getCategory(categoryId, budgetId) {
|
|
422
|
+
return this.withErrorHandling(async () => {
|
|
423
|
+
const api = await this.getApi();
|
|
424
|
+
const id = await this.getBudgetId(budgetId);
|
|
425
|
+
const response = await api.categories.getCategoryById(id, categoryId);
|
|
426
|
+
return response.data.category;
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
async updateCategory(categoryId, data, budgetId) {
|
|
430
|
+
return this.withErrorHandling(async () => {
|
|
431
|
+
const api = await this.getApi();
|
|
432
|
+
const id = await this.getBudgetId(budgetId);
|
|
433
|
+
const response = await api.categories.updateCategory(id, categoryId, data);
|
|
434
|
+
return response.data.category;
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
async getMonthCategory(month, categoryId, budgetId) {
|
|
438
|
+
return this.withErrorHandling(async () => {
|
|
439
|
+
const api = await this.getApi();
|
|
440
|
+
const id = await this.getBudgetId(budgetId);
|
|
441
|
+
const response = await api.categories.getMonthCategoryById(id, month, categoryId);
|
|
442
|
+
return response.data.category;
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
async updateMonthCategory(month, categoryId, data, budgetId) {
|
|
446
|
+
return this.withErrorHandling(async () => {
|
|
447
|
+
const api = await this.getApi();
|
|
448
|
+
const id = await this.getBudgetId(budgetId);
|
|
449
|
+
const response = await api.categories.updateMonthCategory(id, month, categoryId, data);
|
|
450
|
+
return response.data.category;
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
async getPayees(budgetId, lastKnowledgeOfServer) {
|
|
454
|
+
return this.withErrorHandling(async () => {
|
|
455
|
+
const api = await this.getApi();
|
|
456
|
+
const id = await this.getBudgetId(budgetId);
|
|
457
|
+
const response = await api.payees.getPayees(id, lastKnowledgeOfServer);
|
|
458
|
+
return {
|
|
459
|
+
payees: response.data.payees,
|
|
460
|
+
server_knowledge: response.data.server_knowledge
|
|
461
|
+
};
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
async getPayee(payeeId, budgetId) {
|
|
465
|
+
return this.withErrorHandling(async () => {
|
|
466
|
+
const api = await this.getApi();
|
|
467
|
+
const id = await this.getBudgetId(budgetId);
|
|
468
|
+
const response = await api.payees.getPayeeById(id, payeeId);
|
|
469
|
+
return response.data.payee;
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
async updatePayee(payeeId, data, budgetId) {
|
|
473
|
+
return this.withErrorHandling(async () => {
|
|
474
|
+
const api = await this.getApi();
|
|
475
|
+
const id = await this.getBudgetId(budgetId);
|
|
476
|
+
const response = await api.payees.updatePayee(id, payeeId, data);
|
|
477
|
+
return response.data.payee;
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
async getPayeeLocations(budgetId) {
|
|
481
|
+
return this.withErrorHandling(async () => {
|
|
482
|
+
const api = await this.getApi();
|
|
483
|
+
const id = await this.getBudgetId(budgetId);
|
|
484
|
+
const response = await api.payeeLocations.getPayeeLocations(id);
|
|
485
|
+
return response.data.payee_locations;
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
async getPayeeLocation(payeeLocationId, budgetId) {
|
|
489
|
+
return this.withErrorHandling(async () => {
|
|
490
|
+
const api = await this.getApi();
|
|
491
|
+
const id = await this.getBudgetId(budgetId);
|
|
492
|
+
const response = await api.payeeLocations.getPayeeLocationById(id, payeeLocationId);
|
|
493
|
+
return response.data.payee_location;
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
async getPayeeLocationsByPayee(payeeId, budgetId) {
|
|
497
|
+
return this.withErrorHandling(async () => {
|
|
498
|
+
const api = await this.getApi();
|
|
499
|
+
const id = await this.getBudgetId(budgetId);
|
|
500
|
+
const response = await api.payeeLocations.getPayeeLocationsByPayee(id, payeeId);
|
|
501
|
+
return response.data.payee_locations;
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
async getBudgetMonths(budgetId, lastKnowledgeOfServer) {
|
|
505
|
+
return this.withErrorHandling(async () => {
|
|
506
|
+
const api = await this.getApi();
|
|
507
|
+
const id = await this.getBudgetId(budgetId);
|
|
508
|
+
const response = await api.months.getBudgetMonths(id, lastKnowledgeOfServer);
|
|
509
|
+
return {
|
|
510
|
+
months: response.data.months,
|
|
511
|
+
server_knowledge: response.data.server_knowledge
|
|
512
|
+
};
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
async getBudgetMonth(month, budgetId) {
|
|
516
|
+
return this.withErrorHandling(async () => {
|
|
517
|
+
const api = await this.getApi();
|
|
518
|
+
const id = await this.getBudgetId(budgetId);
|
|
519
|
+
const response = await api.months.getBudgetMonth(id, month);
|
|
520
|
+
return response.data.month;
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
async getTransactions(params) {
|
|
524
|
+
return this.withErrorHandling(async () => {
|
|
525
|
+
const api = await this.getApi();
|
|
526
|
+
const id = await this.getBudgetId(params.budgetId);
|
|
527
|
+
const response = await api.transactions.getTransactions(
|
|
528
|
+
id,
|
|
529
|
+
params.sinceDate,
|
|
530
|
+
params.type,
|
|
531
|
+
params.lastKnowledgeOfServer
|
|
532
|
+
);
|
|
533
|
+
return {
|
|
534
|
+
transactions: response.data.transactions,
|
|
535
|
+
server_knowledge: response.data.server_knowledge
|
|
536
|
+
};
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
async getTransactionsByAccount(accountId, params) {
|
|
540
|
+
return this.withErrorHandling(async () => {
|
|
541
|
+
const api = await this.getApi();
|
|
542
|
+
const id = await this.getBudgetId(params.budgetId);
|
|
543
|
+
const response = await api.transactions.getTransactionsByAccount(
|
|
544
|
+
id,
|
|
545
|
+
accountId,
|
|
546
|
+
params.sinceDate,
|
|
547
|
+
params.type,
|
|
548
|
+
params.lastKnowledgeOfServer
|
|
549
|
+
);
|
|
550
|
+
return {
|
|
551
|
+
transactions: response.data.transactions,
|
|
552
|
+
server_knowledge: response.data.server_knowledge
|
|
553
|
+
};
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
async getTransactionsByCategory(categoryId, params) {
|
|
557
|
+
return this.withErrorHandling(async () => {
|
|
558
|
+
const api = await this.getApi();
|
|
559
|
+
const id = await this.getBudgetId(params.budgetId);
|
|
560
|
+
const response = await api.transactions.getTransactionsByCategory(
|
|
561
|
+
id,
|
|
562
|
+
categoryId,
|
|
563
|
+
params.sinceDate,
|
|
564
|
+
params.type,
|
|
565
|
+
params.lastKnowledgeOfServer
|
|
566
|
+
);
|
|
567
|
+
return {
|
|
568
|
+
transactions: response.data.transactions,
|
|
569
|
+
server_knowledge: response.data.server_knowledge
|
|
570
|
+
};
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
async getTransactionsByPayee(payeeId, params) {
|
|
574
|
+
return this.withErrorHandling(async () => {
|
|
575
|
+
const api = await this.getApi();
|
|
576
|
+
const id = await this.getBudgetId(params.budgetId);
|
|
577
|
+
const response = await api.transactions.getTransactionsByPayee(
|
|
578
|
+
id,
|
|
579
|
+
payeeId,
|
|
580
|
+
params.sinceDate,
|
|
581
|
+
params.type,
|
|
582
|
+
params.lastKnowledgeOfServer
|
|
583
|
+
);
|
|
584
|
+
return {
|
|
585
|
+
transactions: response.data.transactions,
|
|
586
|
+
server_knowledge: response.data.server_knowledge
|
|
587
|
+
};
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
async getTransaction(transactionId, budgetId) {
|
|
591
|
+
return this.withErrorHandling(async () => {
|
|
592
|
+
const api = await this.getApi();
|
|
593
|
+
const id = await this.getBudgetId(budgetId);
|
|
594
|
+
const response = await api.transactions.getTransactionById(id, transactionId);
|
|
595
|
+
return response.data.transaction;
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
async createTransaction(transactionData, budgetId) {
|
|
599
|
+
return this.withErrorHandling(async () => {
|
|
600
|
+
const api = await this.getApi();
|
|
601
|
+
const id = await this.getBudgetId(budgetId);
|
|
602
|
+
const response = await api.transactions.createTransaction(id, transactionData);
|
|
603
|
+
return response.data.transaction;
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
async createTransactions(transactionsData, budgetId) {
|
|
607
|
+
return this.withErrorHandling(async () => {
|
|
608
|
+
const api = await this.getApi();
|
|
609
|
+
const id = await this.getBudgetId(budgetId);
|
|
610
|
+
const response = await api.transactions.createTransactions(id, transactionsData);
|
|
611
|
+
return response.data;
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
async updateTransaction(transactionId, transactionData, budgetId) {
|
|
615
|
+
return this.withErrorHandling(async () => {
|
|
616
|
+
const api = await this.getApi();
|
|
617
|
+
const id = await this.getBudgetId(budgetId);
|
|
618
|
+
const response = await api.transactions.updateTransaction(
|
|
619
|
+
id,
|
|
620
|
+
transactionId,
|
|
621
|
+
transactionData
|
|
622
|
+
);
|
|
623
|
+
return response.data.transaction;
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
async updateTransactions(transactionsData, budgetId) {
|
|
627
|
+
return this.withErrorHandling(async () => {
|
|
628
|
+
const api = await this.getApi();
|
|
629
|
+
const id = await this.getBudgetId(budgetId);
|
|
630
|
+
const response = await api.transactions.updateTransactions(id, transactionsData);
|
|
631
|
+
return response.data;
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
async deleteTransaction(transactionId, budgetId) {
|
|
635
|
+
return this.withErrorHandling(async () => {
|
|
636
|
+
const api = await this.getApi();
|
|
637
|
+
const id = await this.getBudgetId(budgetId);
|
|
638
|
+
const response = await api.transactions.deleteTransaction(id, transactionId);
|
|
639
|
+
return response.data.transaction;
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
async importTransactions(budgetId) {
|
|
643
|
+
return this.withErrorHandling(async () => {
|
|
644
|
+
const api = await this.getApi();
|
|
645
|
+
const id = await this.getBudgetId(budgetId);
|
|
646
|
+
const response = await api.transactions.importTransactions(id);
|
|
647
|
+
return response.data.transaction_ids;
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
async getScheduledTransactions(budgetId, lastKnowledgeOfServer) {
|
|
651
|
+
return this.withErrorHandling(async () => {
|
|
652
|
+
const api = await this.getApi();
|
|
653
|
+
const id = await this.getBudgetId(budgetId);
|
|
654
|
+
const response = await api.scheduledTransactions.getScheduledTransactions(
|
|
655
|
+
id,
|
|
656
|
+
lastKnowledgeOfServer
|
|
657
|
+
);
|
|
658
|
+
return {
|
|
659
|
+
scheduled_transactions: response.data.scheduled_transactions,
|
|
660
|
+
server_knowledge: response.data.server_knowledge
|
|
661
|
+
};
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
async getScheduledTransaction(scheduledTransactionId, budgetId) {
|
|
665
|
+
return this.withErrorHandling(async () => {
|
|
666
|
+
const api = await this.getApi();
|
|
667
|
+
const id = await this.getBudgetId(budgetId);
|
|
668
|
+
const response = await api.scheduledTransactions.getScheduledTransactionById(
|
|
669
|
+
id,
|
|
670
|
+
scheduledTransactionId
|
|
671
|
+
);
|
|
672
|
+
return response.data.scheduled_transaction;
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
async createScheduledTransaction(data, budgetId) {
|
|
676
|
+
return this.withErrorHandling(async () => {
|
|
677
|
+
const api = await this.getApi();
|
|
678
|
+
const id = await this.getBudgetId(budgetId);
|
|
679
|
+
const response = await api.scheduledTransactions.createScheduledTransaction(id, data);
|
|
680
|
+
return response.data.scheduled_transaction;
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
async updateScheduledTransaction(scheduledTransactionId, data, budgetId) {
|
|
684
|
+
return this.withErrorHandling(async () => {
|
|
685
|
+
const api = await this.getApi();
|
|
686
|
+
const id = await this.getBudgetId(budgetId);
|
|
687
|
+
const response = await api.scheduledTransactions.updateScheduledTransaction(
|
|
688
|
+
id,
|
|
689
|
+
scheduledTransactionId,
|
|
690
|
+
data
|
|
691
|
+
);
|
|
692
|
+
return response.data.scheduled_transaction;
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
async deleteScheduledTransaction(scheduledTransactionId, budgetId) {
|
|
696
|
+
return this.withErrorHandling(async () => {
|
|
697
|
+
const api = await this.getApi();
|
|
698
|
+
const id = await this.getBudgetId(budgetId);
|
|
699
|
+
const response = await api.scheduledTransactions.deleteScheduledTransaction(
|
|
700
|
+
id,
|
|
701
|
+
scheduledTransactionId
|
|
702
|
+
);
|
|
703
|
+
return response.data.scheduled_transaction;
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
async rawApiCall(method, path, data, budgetId) {
|
|
707
|
+
return this.withErrorHandling(async () => {
|
|
708
|
+
await this.getApi();
|
|
709
|
+
const fullPath = path.includes("{budget_id}") ? path.replace("{budget_id}", await this.getBudgetId(budgetId)) : path;
|
|
710
|
+
const url = `https://api.ynab.com/v1${fullPath}`;
|
|
711
|
+
const headers = {
|
|
712
|
+
Authorization: `Bearer ${await auth.getAccessToken()}`,
|
|
713
|
+
"Content-Type": "application/json"
|
|
714
|
+
};
|
|
715
|
+
const httpMethod = method.toUpperCase();
|
|
716
|
+
const hasBody = ["POST", "PUT", "PATCH"].includes(httpMethod);
|
|
717
|
+
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(httpMethod)) {
|
|
718
|
+
throw new YnabCliError(`Unsupported HTTP method: ${method}`, 400);
|
|
719
|
+
}
|
|
720
|
+
const response = await fetch(url, {
|
|
721
|
+
method: httpMethod,
|
|
722
|
+
headers,
|
|
723
|
+
...hasBody && { body: JSON.stringify(data) }
|
|
724
|
+
});
|
|
725
|
+
if (!response.ok) {
|
|
726
|
+
const errorData = await response.json();
|
|
727
|
+
throw { error: sanitizeApiError(errorData.error || errorData) };
|
|
728
|
+
}
|
|
729
|
+
return await response.json();
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
var client = new YnabClient();
|
|
734
|
+
|
|
735
|
+
// src/lib/command-utils.ts
|
|
736
|
+
function withErrorHandling(fn) {
|
|
737
|
+
return async (...args) => {
|
|
738
|
+
try {
|
|
739
|
+
await fn(...args);
|
|
740
|
+
} catch (error) {
|
|
741
|
+
handleYnabError(error);
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
async function confirmDelete(itemType, skipConfirmation = false) {
|
|
746
|
+
if (skipConfirmation || !isInteractive()) {
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
const confirmed = await promptForConfirmation(
|
|
750
|
+
`Are you sure you want to delete this ${itemType}?`
|
|
751
|
+
);
|
|
752
|
+
if (!confirmed) {
|
|
753
|
+
outputSuccess({ message: "Operation cancelled" });
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
function buildUpdateObject(options, mapping) {
|
|
759
|
+
const result = {};
|
|
760
|
+
for (const [optionKey, targetKey] of Object.entries(mapping)) {
|
|
761
|
+
if (options[optionKey] !== void 0) {
|
|
762
|
+
result[targetKey] = options[optionKey];
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return result;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// src/commands/auth.ts
|
|
769
|
+
function createAuthCommand() {
|
|
770
|
+
const cmd = new Command("auth").description("Authentication management");
|
|
771
|
+
cmd.command("login").description("Configure access token").action(withErrorHandling(async () => {
|
|
772
|
+
const token = await promptForAccessToken();
|
|
773
|
+
await auth.setAccessToken(token);
|
|
774
|
+
try {
|
|
775
|
+
const user = await client.getUser();
|
|
776
|
+
outputSuccess({
|
|
777
|
+
message: "Successfully authenticated",
|
|
778
|
+
user: { id: user?.id }
|
|
779
|
+
});
|
|
780
|
+
} catch (error) {
|
|
781
|
+
await auth.deleteAccessToken();
|
|
782
|
+
throw error;
|
|
783
|
+
}
|
|
784
|
+
}));
|
|
785
|
+
cmd.command("status").description("Check authentication status").action(withErrorHandling(async () => {
|
|
786
|
+
const isAuthenticated = await auth.isAuthenticated();
|
|
787
|
+
if (!isAuthenticated) {
|
|
788
|
+
outputJson({ authenticated: false, message: "Not authenticated" });
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
try {
|
|
792
|
+
const user = await client.getUser();
|
|
793
|
+
outputSuccess({ authenticated: true, user: { id: user?.id } });
|
|
794
|
+
} catch (error) {
|
|
795
|
+
outputJson({ authenticated: false, message: "Token exists but is invalid" });
|
|
796
|
+
}
|
|
797
|
+
}));
|
|
798
|
+
cmd.command("logout").description("Remove stored credentials").action(withErrorHandling(async () => {
|
|
799
|
+
await auth.logout();
|
|
800
|
+
outputSuccess({ message: "Successfully logged out" });
|
|
801
|
+
}));
|
|
802
|
+
return cmd;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// src/commands/user.ts
|
|
806
|
+
import { Command as Command2 } from "commander";
|
|
807
|
+
function createUserCommand() {
|
|
808
|
+
const cmd = new Command2("user").description("User information");
|
|
809
|
+
cmd.command("info").description("Get authenticated user information").action(withErrorHandling(async () => {
|
|
810
|
+
const user = await client.getUser();
|
|
811
|
+
outputSuccess(user);
|
|
812
|
+
}));
|
|
813
|
+
return cmd;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// src/commands/budgets.ts
|
|
817
|
+
import { Command as Command3 } from "commander";
|
|
818
|
+
function createBudgetsCommand() {
|
|
819
|
+
const cmd = new Command3("budgets").description("Budget operations");
|
|
820
|
+
cmd.command("list").description("List all budgets").option("--include-accounts", "Include accounts in response").action(withErrorHandling(async (options) => {
|
|
821
|
+
const result = await client.getBudgets(options.includeAccounts);
|
|
822
|
+
outputSuccess(result?.budgets);
|
|
823
|
+
}));
|
|
824
|
+
cmd.command("view").description("View budget details (uses default if no id provided)").argument("[id]", "Budget ID").action(withErrorHandling(async (id) => {
|
|
825
|
+
const result = await client.getBudget(id);
|
|
826
|
+
outputSuccess(result?.budget);
|
|
827
|
+
}));
|
|
828
|
+
cmd.command("settings").description("View budget settings").argument("[id]", "Budget ID").action(withErrorHandling(async (id) => {
|
|
829
|
+
const settings = await client.getBudgetSettings(id);
|
|
830
|
+
outputSuccess(settings);
|
|
831
|
+
}));
|
|
832
|
+
cmd.command("set-default").description("Set default budget for commands").argument("<id>", "Budget ID").action(withErrorHandling(async (id) => {
|
|
833
|
+
const result = await client.getBudgets();
|
|
834
|
+
const budget = result?.budgets.find((b) => b.id === id);
|
|
835
|
+
if (!budget) {
|
|
836
|
+
throw new YnabCliError(`Budget with ID ${id} not found`, 404);
|
|
837
|
+
}
|
|
838
|
+
config.setDefaultBudget(id);
|
|
839
|
+
outputSuccess({
|
|
840
|
+
message: "Default budget set",
|
|
841
|
+
budget: { id: budget.id, name: budget.name }
|
|
842
|
+
});
|
|
843
|
+
}));
|
|
844
|
+
return cmd;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// src/commands/accounts.ts
|
|
848
|
+
import { Command as Command4 } from "commander";
|
|
849
|
+
function createAccountsCommand() {
|
|
850
|
+
const cmd = new Command4("accounts").description("Account operations");
|
|
851
|
+
cmd.command("list").description("List all accounts").option("-b, --budget <id>", "Budget ID").action(withErrorHandling(async (options) => {
|
|
852
|
+
const result = await client.getAccounts(options.budget);
|
|
853
|
+
outputSuccess(result?.accounts);
|
|
854
|
+
}));
|
|
855
|
+
cmd.command("view").description("View account details").argument("<id>", "Account ID").option("-b, --budget <id>", "Budget ID").action(withErrorHandling(async (id, options) => {
|
|
856
|
+
const account = await client.getAccount(id, options.budget);
|
|
857
|
+
outputSuccess(account);
|
|
858
|
+
}));
|
|
859
|
+
cmd.command("transactions").description("List transactions for account").argument("<id>", "Account ID").option("-b, --budget <id>", "Budget ID").option("--since <date>", "Filter transactions since date (YYYY-MM-DD)").option("--type <type>", "Filter by transaction type").action(withErrorHandling(async (id, options) => {
|
|
860
|
+
const result = await client.getTransactionsByAccount(id, {
|
|
861
|
+
budgetId: options.budget,
|
|
862
|
+
sinceDate: options.since,
|
|
863
|
+
type: options.type
|
|
864
|
+
});
|
|
865
|
+
outputSuccess(result?.transactions);
|
|
866
|
+
}));
|
|
867
|
+
return cmd;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// src/commands/categories.ts
|
|
871
|
+
import { Command as Command5 } from "commander";
|
|
872
|
+
function createCategoriesCommand() {
|
|
873
|
+
const cmd = new Command5("categories").description("Category operations");
|
|
874
|
+
cmd.command("list").description("List all categories").option("-b, --budget <id>", "Budget ID").option("--last-knowledge <number>", "Last knowledge of server", parseInt).action(withErrorHandling(async (options) => {
|
|
875
|
+
const result = await client.getCategories(options.budget, options.lastKnowledge);
|
|
876
|
+
outputSuccess(result?.category_groups);
|
|
877
|
+
}));
|
|
878
|
+
cmd.command("view").description("View category details").argument("<id>", "Category ID").option("-b, --budget <id>", "Budget ID").action(withErrorHandling(async (id, options) => {
|
|
879
|
+
const category = await client.getCategory(id, options.budget);
|
|
880
|
+
outputSuccess(category);
|
|
881
|
+
}));
|
|
882
|
+
cmd.command("budget").description("Update category budget for a month").argument("<id>", "Category ID").requiredOption("--month <month>", "Month in YYYY-MM format").requiredOption("--amount <amount>", "Budget amount in currency units (e.g., 100.50)", parseFloat).option("-b, --budget <id>", "Budget ID").action(withErrorHandling(async (id, options) => {
|
|
883
|
+
if (isNaN(options.amount)) {
|
|
884
|
+
throw new YnabCliError("Amount must be a valid number", 400);
|
|
885
|
+
}
|
|
886
|
+
const milliunits = amountToMilliunits(options.amount);
|
|
887
|
+
const category = await client.updateMonthCategory(
|
|
888
|
+
options.month,
|
|
889
|
+
id,
|
|
890
|
+
{ category: { budgeted: milliunits } },
|
|
891
|
+
options.budget
|
|
892
|
+
);
|
|
893
|
+
outputSuccess(category);
|
|
894
|
+
}));
|
|
895
|
+
cmd.command("transactions").description("List transactions for category").argument("<id>", "Category ID").option("-b, --budget <id>", "Budget ID").option("--since <date>", "Filter transactions since date (YYYY-MM-DD)").option("--type <type>", "Filter by transaction type").option("--last-knowledge <number>", "Last knowledge of server", parseInt).action(withErrorHandling(async (id, options) => {
|
|
896
|
+
const result = await client.getTransactionsByCategory(id, {
|
|
897
|
+
budgetId: options.budget,
|
|
898
|
+
sinceDate: options.since,
|
|
899
|
+
type: options.type,
|
|
900
|
+
lastKnowledgeOfServer: options.lastKnowledge
|
|
901
|
+
});
|
|
902
|
+
outputSuccess(result?.transactions);
|
|
903
|
+
}));
|
|
904
|
+
return cmd;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// src/commands/transactions.ts
|
|
908
|
+
import { Command as Command6 } from "commander";
|
|
909
|
+
|
|
910
|
+
// src/lib/schemas.ts
|
|
911
|
+
import { z } from "zod";
|
|
912
|
+
var TransactionSplitSchema = z.array(
|
|
913
|
+
z.object({
|
|
914
|
+
amount: z.number(),
|
|
915
|
+
category_id: z.string().nullable().optional(),
|
|
916
|
+
memo: z.string().optional(),
|
|
917
|
+
payee_id: z.string().optional()
|
|
918
|
+
})
|
|
919
|
+
);
|
|
920
|
+
var ApiDataSchema = z.record(z.any());
|
|
921
|
+
function validateJson(data, schema, fieldName) {
|
|
922
|
+
try {
|
|
923
|
+
return schema.parse(data);
|
|
924
|
+
} catch (error) {
|
|
925
|
+
if (error instanceof z.ZodError) {
|
|
926
|
+
const issues = error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ");
|
|
927
|
+
throw new YnabCliError(`Invalid ${fieldName}: ${issues}`, 400);
|
|
928
|
+
}
|
|
929
|
+
throw error;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// src/commands/transactions.ts
|
|
934
|
+
function buildTransactionData(options) {
|
|
935
|
+
if (!options.account) {
|
|
936
|
+
throw new YnabCliError("--account is required in non-interactive mode", 400);
|
|
937
|
+
}
|
|
938
|
+
if (options.amount === void 0) {
|
|
939
|
+
throw new YnabCliError("--amount is required in non-interactive mode", 400);
|
|
940
|
+
}
|
|
941
|
+
return {
|
|
942
|
+
account_id: options.account,
|
|
943
|
+
date: options.date || (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
|
|
944
|
+
amount: amountToMilliunits(options.amount),
|
|
945
|
+
payee_name: options.payeeName,
|
|
946
|
+
payee_id: options.payeeId,
|
|
947
|
+
category_id: options.categoryId,
|
|
948
|
+
memo: options.memo,
|
|
949
|
+
cleared: options.cleared,
|
|
950
|
+
approved: options.approved
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
function createTransactionsCommand() {
|
|
954
|
+
const cmd = new Command6("transactions").description("Transaction operations");
|
|
955
|
+
cmd.command("list").description("List transactions").option("-b, --budget <id>", "Budget ID").option("--account <id>", "Filter by account ID").option("--category <id>", "Filter by category ID").option("--payee <id>", "Filter by payee ID").option("--since <date>", "Filter transactions since date (YYYY-MM-DD)").option("--until <date>", "Filter transactions until date (YYYY-MM-DD)").option("--type <type>", "Filter by transaction type").option("--approved <value>", "Filter by approval status: true or false").option("--status <statuses>", "Filter by cleared status: cleared, uncleared, reconciled (comma-separated for multiple)").option("--min-amount <amount>", "Minimum amount in currency units (e.g., 10.50)", parseFloat).option("--max-amount <amount>", "Maximum amount in currency units (e.g., 100.00)", parseFloat).option("--fields <fields>", "Comma-separated list of fields to include (e.g., id,date,amount,memo)").action(withErrorHandling(async (options) => {
|
|
956
|
+
const params = {
|
|
957
|
+
budgetId: options.budget,
|
|
958
|
+
sinceDate: options.since,
|
|
959
|
+
type: options.type
|
|
960
|
+
};
|
|
961
|
+
const result = options.account ? await client.getTransactionsByAccount(options.account, params) : options.category ? await client.getTransactionsByCategory(options.category, params) : options.payee ? await client.getTransactionsByPayee(options.payee, params) : await client.getTransactions(params);
|
|
962
|
+
let transactions = result?.transactions || [];
|
|
963
|
+
transactions = applyTransactionFilters(transactions, {
|
|
964
|
+
until: options.until,
|
|
965
|
+
approved: options.approved,
|
|
966
|
+
status: options.status,
|
|
967
|
+
minAmount: options.minAmount,
|
|
968
|
+
maxAmount: options.maxAmount
|
|
969
|
+
});
|
|
970
|
+
transactions = applyFieldSelection(transactions, options.fields);
|
|
971
|
+
outputSuccess(transactions);
|
|
972
|
+
}));
|
|
973
|
+
cmd.command("view").description("View single transaction").argument("<id>", "Transaction ID").option("-b, --budget <id>", "Budget ID").action(withErrorHandling(async (id, options) => {
|
|
974
|
+
const transaction = await client.getTransaction(id, options.budget);
|
|
975
|
+
outputSuccess(transaction);
|
|
976
|
+
}));
|
|
977
|
+
cmd.command("create").description("Create transaction").option("-b, --budget <id>", "Budget ID").option("--account <id>", "Account ID").option("--date <date>", "Date (YYYY-MM-DD)").option("--amount <amount>", "Amount in currency units (e.g., 10.50)", parseFloat).option("--payee-name <name>", "Payee name").option("--payee-id <id>", "Payee ID").option("--category-id <id>", "Category ID").option("--memo <memo>", "Memo").option("--cleared <status>", "Cleared status (cleared, uncleared, reconciled)").option("--approved", "Mark as approved").action(withErrorHandling(async (options) => {
|
|
978
|
+
const shouldPrompt = isInteractive() && !options.account && !options.amount;
|
|
979
|
+
const transactionData = shouldPrompt ? await promptForTransaction() : buildTransactionData(options);
|
|
980
|
+
const transaction = await client.createTransaction(
|
|
981
|
+
{ transaction: transactionData },
|
|
982
|
+
options.budget
|
|
983
|
+
);
|
|
984
|
+
outputSuccess(transaction);
|
|
985
|
+
}));
|
|
986
|
+
cmd.command("update").description("Update transaction").argument("<id>", "Transaction ID").option("-b, --budget <id>", "Budget ID").option("--account <id>", "Account ID").option("--date <date>", "Date (YYYY-MM-DD)").option("--amount <amount>", "Amount in currency units", parseFloat).option("--payee-name <name>", "Payee name").option("--payee-id <id>", "Payee ID").option("--category-id <id>", "Category ID").option("--memo <memo>", "Memo").option("--cleared <status>", "Cleared status").option("--approved", "Mark as approved").action(withErrorHandling(async (id, options) => {
|
|
987
|
+
const transactionData = buildUpdateObject(options, {
|
|
988
|
+
account: "account_id",
|
|
989
|
+
date: "date",
|
|
990
|
+
payeeName: "payee_name",
|
|
991
|
+
payeeId: "payee_id",
|
|
992
|
+
categoryId: "category_id",
|
|
993
|
+
memo: "memo",
|
|
994
|
+
cleared: "cleared",
|
|
995
|
+
approved: "approved"
|
|
996
|
+
});
|
|
997
|
+
if (options.amount !== void 0) {
|
|
998
|
+
transactionData.amount = amountToMilliunits(options.amount);
|
|
999
|
+
}
|
|
1000
|
+
const transaction = await client.updateTransaction(
|
|
1001
|
+
id,
|
|
1002
|
+
{ transaction: transactionData },
|
|
1003
|
+
options.budget
|
|
1004
|
+
);
|
|
1005
|
+
outputSuccess(transaction);
|
|
1006
|
+
}));
|
|
1007
|
+
cmd.command("delete").description("Delete transaction").argument("<id>", "Transaction ID").option("-b, --budget <id>", "Budget ID").option("-y, --yes", "Skip confirmation").action(withErrorHandling(async (id, options) => {
|
|
1008
|
+
if (!await confirmDelete("transaction", options.yes)) {
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
const transaction = await client.deleteTransaction(id, options.budget);
|
|
1012
|
+
outputSuccess({ message: "Transaction deleted", transaction });
|
|
1013
|
+
}));
|
|
1014
|
+
cmd.command("import").description("Import transactions").option("-b, --budget <id>", "Budget ID").action(withErrorHandling(async (options) => {
|
|
1015
|
+
const transactionIds = await client.importTransactions(options.budget);
|
|
1016
|
+
outputSuccess({ transaction_ids: transactionIds });
|
|
1017
|
+
}));
|
|
1018
|
+
cmd.command("split").description("Split transaction into multiple categories").argument("<id>", "Transaction ID").requiredOption("--splits <json>", 'JSON array of splits: [{"amount": -21400, "category_id": "xxx", "memo": "..."}]').option("-b, --budget <id>", "Budget ID").action(withErrorHandling(async (id, options) => {
|
|
1019
|
+
let parsedSplits;
|
|
1020
|
+
try {
|
|
1021
|
+
parsedSplits = JSON.parse(options.splits);
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
throw new YnabCliError("Invalid JSON in --splits parameter", 400);
|
|
1024
|
+
}
|
|
1025
|
+
const splits = validateJson(parsedSplits, TransactionSplitSchema, "transaction splits");
|
|
1026
|
+
const transaction = await client.updateTransaction(
|
|
1027
|
+
id,
|
|
1028
|
+
{
|
|
1029
|
+
transaction: {
|
|
1030
|
+
category_id: null,
|
|
1031
|
+
subtransactions: splits
|
|
1032
|
+
}
|
|
1033
|
+
},
|
|
1034
|
+
options.budget
|
|
1035
|
+
);
|
|
1036
|
+
outputSuccess(transaction);
|
|
1037
|
+
}));
|
|
1038
|
+
cmd.command("search").description("Search transactions").option("-b, --budget <id>", "Budget ID").option("--memo <text>", "Search in memo field").option("--payee-name <name>", "Search in payee name").option("--amount <amount>", "Search for exact amount in currency units", parseFloat).option("--since <date>", "Search transactions since date (YYYY-MM-DD)").option("--until <date>", "Search transactions until date (YYYY-MM-DD)").option("--approved <value>", "Filter by approval status: true or false").option("--status <statuses>", "Filter by cleared status: cleared, uncleared, reconciled (comma-separated)").option("--fields <fields>", "Comma-separated list of fields to include").action(withErrorHandling(async (options) => {
|
|
1039
|
+
if (!options.memo && !options.payeeName && options.amount === void 0) {
|
|
1040
|
+
throw new YnabCliError("At least one search criteria required: --memo, --payee-name, or --amount", 400);
|
|
1041
|
+
}
|
|
1042
|
+
const params = {
|
|
1043
|
+
budgetId: options.budget,
|
|
1044
|
+
sinceDate: options.since
|
|
1045
|
+
};
|
|
1046
|
+
const result = await client.getTransactions(params);
|
|
1047
|
+
let transactions = result?.transactions || [];
|
|
1048
|
+
if (options.memo) {
|
|
1049
|
+
const searchTerm = options.memo.toLowerCase();
|
|
1050
|
+
transactions = transactions.filter(
|
|
1051
|
+
(t) => t.memo?.toLowerCase().includes(searchTerm)
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
if (options.payeeName) {
|
|
1055
|
+
const searchTerm = options.payeeName.toLowerCase();
|
|
1056
|
+
transactions = transactions.filter(
|
|
1057
|
+
(t) => t.payee_name?.toLowerCase().includes(searchTerm)
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
if (options.amount !== void 0) {
|
|
1061
|
+
const amountMilliunits = amountToMilliunits(options.amount);
|
|
1062
|
+
transactions = transactions.filter((t) => t.amount === amountMilliunits);
|
|
1063
|
+
}
|
|
1064
|
+
transactions = applyTransactionFilters(transactions, {
|
|
1065
|
+
until: options.until,
|
|
1066
|
+
approved: options.approved,
|
|
1067
|
+
status: options.status
|
|
1068
|
+
});
|
|
1069
|
+
transactions = applyFieldSelection(transactions, options.fields);
|
|
1070
|
+
outputSuccess(transactions);
|
|
1071
|
+
}));
|
|
1072
|
+
return cmd;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// src/commands/scheduled.ts
|
|
1076
|
+
import { Command as Command7 } from "commander";
|
|
1077
|
+
function createScheduledCommand() {
|
|
1078
|
+
const cmd = new Command7("scheduled").description("Scheduled transaction operations");
|
|
1079
|
+
cmd.command("list").description("List all scheduled transactions").option("-b, --budget <id>", "Budget ID").option("--last-knowledge <number>", "Last knowledge of server", parseInt).action(withErrorHandling(async (options) => {
|
|
1080
|
+
const result = await client.getScheduledTransactions(options.budget, options.lastKnowledge);
|
|
1081
|
+
outputSuccess(result?.scheduled_transactions);
|
|
1082
|
+
}));
|
|
1083
|
+
cmd.command("view").description("View scheduled transaction").argument("<id>", "Scheduled transaction ID").option("-b, --budget <id>", "Budget ID").action(withErrorHandling(async (id, options) => {
|
|
1084
|
+
const scheduledTransaction = await client.getScheduledTransaction(id, options.budget);
|
|
1085
|
+
outputSuccess(scheduledTransaction);
|
|
1086
|
+
}));
|
|
1087
|
+
cmd.command("delete").description("Delete scheduled transaction").argument("<id>", "Scheduled transaction ID").option("-b, --budget <id>", "Budget ID").option("-y, --yes", "Skip confirmation").action(withErrorHandling(async (id, options) => {
|
|
1088
|
+
if (!await confirmDelete("scheduled transaction", options.yes)) {
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
const scheduledTransaction = await client.deleteScheduledTransaction(id, options.budget);
|
|
1092
|
+
outputSuccess({
|
|
1093
|
+
message: "Scheduled transaction deleted",
|
|
1094
|
+
scheduled_transaction: scheduledTransaction
|
|
1095
|
+
});
|
|
1096
|
+
}));
|
|
1097
|
+
return cmd;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// src/commands/payees.ts
|
|
1101
|
+
import { Command as Command8 } from "commander";
|
|
1102
|
+
function createPayeesCommand() {
|
|
1103
|
+
const cmd = new Command8("payees").description("Payee operations");
|
|
1104
|
+
cmd.command("list").description("List all payees").option("-b, --budget <id>", "Budget ID").option("--last-knowledge <number>", "Last knowledge of server", parseInt).action(withErrorHandling(async (options) => {
|
|
1105
|
+
const result = await client.getPayees(options.budget, options.lastKnowledge);
|
|
1106
|
+
outputSuccess(result?.payees);
|
|
1107
|
+
}));
|
|
1108
|
+
cmd.command("view").description("View payee details").argument("<id>", "Payee ID").option("-b, --budget <id>", "Budget ID").action(withErrorHandling(async (id, options) => {
|
|
1109
|
+
const payee = await client.getPayee(id, options.budget);
|
|
1110
|
+
outputSuccess(payee);
|
|
1111
|
+
}));
|
|
1112
|
+
cmd.command("update").description("Rename payee").argument("<id>", "Payee ID").requiredOption("--name <name>", "New payee name").option("-b, --budget <id>", "Budget ID").action(withErrorHandling(async (id, options) => {
|
|
1113
|
+
if (!options.name?.trim()) {
|
|
1114
|
+
throw new YnabCliError("Name cannot be empty", 400);
|
|
1115
|
+
}
|
|
1116
|
+
const payee = await client.updatePayee(
|
|
1117
|
+
id,
|
|
1118
|
+
{ payee: { name: options.name } },
|
|
1119
|
+
options.budget
|
|
1120
|
+
);
|
|
1121
|
+
outputSuccess(payee);
|
|
1122
|
+
}));
|
|
1123
|
+
cmd.command("locations").description("List locations for payee").argument("<id>", "Payee ID").option("-b, --budget <id>", "Budget ID").action(withErrorHandling(async (id, options) => {
|
|
1124
|
+
const locations = await client.getPayeeLocationsByPayee(id, options.budget);
|
|
1125
|
+
outputSuccess(locations);
|
|
1126
|
+
}));
|
|
1127
|
+
cmd.command("transactions").description("List transactions for payee").argument("<id>", "Payee ID").option("-b, --budget <id>", "Budget ID").option("--since <date>", "Filter transactions since date (YYYY-MM-DD)").option("--type <type>", "Filter by transaction type").option("--last-knowledge <number>", "Last knowledge of server", parseInt).action(withErrorHandling(async (id, options) => {
|
|
1128
|
+
const result = await client.getTransactionsByPayee(id, {
|
|
1129
|
+
budgetId: options.budget,
|
|
1130
|
+
sinceDate: options.since,
|
|
1131
|
+
type: options.type,
|
|
1132
|
+
lastKnowledgeOfServer: options.lastKnowledge
|
|
1133
|
+
});
|
|
1134
|
+
outputSuccess(result?.transactions);
|
|
1135
|
+
}));
|
|
1136
|
+
return cmd;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// src/commands/months.ts
|
|
1140
|
+
import { Command as Command9 } from "commander";
|
|
1141
|
+
function createMonthsCommand() {
|
|
1142
|
+
const cmd = new Command9("months").description("Monthly budget operations");
|
|
1143
|
+
cmd.command("list").description("List all budget months").option("-b, --budget <id>", "Budget ID").option("--last-knowledge <number>", "Last knowledge of server", parseInt).action(withErrorHandling(async (options) => {
|
|
1144
|
+
const result = await client.getBudgetMonths(options.budget, options.lastKnowledge);
|
|
1145
|
+
outputSuccess(result?.months);
|
|
1146
|
+
}));
|
|
1147
|
+
cmd.command("view").description("View specific month details").argument("<month>", "Month in YYYY-MM format").option("-b, --budget <id>", "Budget ID").action(withErrorHandling(async (month, options) => {
|
|
1148
|
+
const monthData = await client.getBudgetMonth(month, options.budget);
|
|
1149
|
+
outputSuccess(monthData);
|
|
1150
|
+
}));
|
|
1151
|
+
return cmd;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// src/commands/api.ts
|
|
1155
|
+
import { Command as Command10 } from "commander";
|
|
1156
|
+
var VALID_HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
1157
|
+
function createApiCommand() {
|
|
1158
|
+
const cmd = new Command10("api").description("Raw API access");
|
|
1159
|
+
cmd.argument("<method>", "HTTP method (GET, POST, PUT, PATCH, DELETE)").argument("<path>", "API path (e.g., /budgets or /budgets/{budget_id}/transactions)").option("-b, --budget <id>", "Budget ID (used to replace {budget_id} in path)").option("--data <json>", "JSON data for POST/PUT/PATCH requests").description("Make raw API calls to YNAB").action(withErrorHandling(async (method, path, options) => {
|
|
1160
|
+
const upperMethod = method.toUpperCase();
|
|
1161
|
+
if (!VALID_HTTP_METHODS.includes(upperMethod)) {
|
|
1162
|
+
throw new YnabCliError(
|
|
1163
|
+
`Invalid HTTP method: ${method}. Must be one of: ${VALID_HTTP_METHODS.join(", ")}`,
|
|
1164
|
+
400
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
let data;
|
|
1168
|
+
if (options.data) {
|
|
1169
|
+
let parsedData;
|
|
1170
|
+
try {
|
|
1171
|
+
parsedData = JSON.parse(options.data);
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
throw new YnabCliError("Invalid JSON in --data parameter", 400);
|
|
1174
|
+
}
|
|
1175
|
+
data = validateJson(parsedData, ApiDataSchema, "API data");
|
|
1176
|
+
}
|
|
1177
|
+
const result = await client.rawApiCall(upperMethod, path, data, options.budget);
|
|
1178
|
+
outputJson(result);
|
|
1179
|
+
}));
|
|
1180
|
+
return cmd;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// src/cli.ts
|
|
1184
|
+
var program = new Command11();
|
|
1185
|
+
program.name("ynab").description("A command-line interface for You Need a Budget (YNAB)").version("1.0.0").option("-c, --compact", "Minified JSON output (single line)").hook("preAction", (thisCommand) => {
|
|
1186
|
+
const options = thisCommand.opts();
|
|
1187
|
+
setOutputOptions({
|
|
1188
|
+
compact: options.compact
|
|
1189
|
+
});
|
|
1190
|
+
});
|
|
1191
|
+
program.addCommand(createAuthCommand());
|
|
1192
|
+
program.addCommand(createUserCommand());
|
|
1193
|
+
program.addCommand(createBudgetsCommand());
|
|
1194
|
+
program.addCommand(createAccountsCommand());
|
|
1195
|
+
program.addCommand(createCategoriesCommand());
|
|
1196
|
+
program.addCommand(createTransactionsCommand());
|
|
1197
|
+
program.addCommand(createScheduledCommand());
|
|
1198
|
+
program.addCommand(createPayeesCommand());
|
|
1199
|
+
program.addCommand(createMonthsCommand());
|
|
1200
|
+
program.addCommand(createApiCommand());
|
|
1201
|
+
program.parse();
|
|
1202
|
+
//# sourceMappingURL=cli.js.map
|