@stephendolan/ynab-cli 2.6.0 → 2.8.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/dist/cli.js +409 -26
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -187,6 +187,92 @@ function applyTransactionFilters(transactions, filters) {
|
|
|
187
187
|
}
|
|
188
188
|
return filtered;
|
|
189
189
|
}
|
|
190
|
+
function summarizeTransactions(transactions, options) {
|
|
191
|
+
let totalAmount = 0;
|
|
192
|
+
const dates = [];
|
|
193
|
+
const byPayee = /* @__PURE__ */ new Map();
|
|
194
|
+
const byCategory = /* @__PURE__ */ new Map();
|
|
195
|
+
const byCleared = /* @__PURE__ */ new Map();
|
|
196
|
+
const byApproval = /* @__PURE__ */ new Map();
|
|
197
|
+
for (const t of transactions) {
|
|
198
|
+
totalAmount += t.amount;
|
|
199
|
+
dates.push(t.date);
|
|
200
|
+
const payeeKey = t.payee_id || t.payee_name || "(none)";
|
|
201
|
+
const payeeEntry = byPayee.get(payeeKey) || {
|
|
202
|
+
payee_id: t.payee_id || null,
|
|
203
|
+
payee_name: t.payee_name || null,
|
|
204
|
+
count: 0,
|
|
205
|
+
total_amount: 0
|
|
206
|
+
};
|
|
207
|
+
payeeEntry.count++;
|
|
208
|
+
payeeEntry.total_amount += t.amount;
|
|
209
|
+
byPayee.set(payeeKey, payeeEntry);
|
|
210
|
+
const catKey = t.category_id || t.category_name || "(uncategorized)";
|
|
211
|
+
const catEntry = byCategory.get(catKey) || {
|
|
212
|
+
category_id: t.category_id || null,
|
|
213
|
+
category_name: t.category_name || null,
|
|
214
|
+
count: 0,
|
|
215
|
+
total_amount: 0
|
|
216
|
+
};
|
|
217
|
+
catEntry.count++;
|
|
218
|
+
catEntry.total_amount += t.amount;
|
|
219
|
+
byCategory.set(catKey, catEntry);
|
|
220
|
+
const clearedEntry = byCleared.get(t.cleared) || { count: 0, total_amount: 0 };
|
|
221
|
+
clearedEntry.count++;
|
|
222
|
+
clearedEntry.total_amount += t.amount;
|
|
223
|
+
byCleared.set(t.cleared, clearedEntry);
|
|
224
|
+
const approvalKey = String(t.approved);
|
|
225
|
+
const approvalEntry = byApproval.get(approvalKey) || { count: 0, total_amount: 0 };
|
|
226
|
+
approvalEntry.count++;
|
|
227
|
+
approvalEntry.total_amount += t.amount;
|
|
228
|
+
byApproval.set(approvalKey, approvalEntry);
|
|
229
|
+
}
|
|
230
|
+
const sortByAbsAmount = (entries) => entries.sort((a, b) => Math.abs(b.total_amount) - Math.abs(a.total_amount));
|
|
231
|
+
const truncate = (entries, rollupFactory) => {
|
|
232
|
+
const top = options?.top;
|
|
233
|
+
if (!top || top <= 0 || entries.length <= top) return entries;
|
|
234
|
+
const kept = entries.slice(0, top);
|
|
235
|
+
const rest = entries.slice(top);
|
|
236
|
+
const rollup = rollupFactory({
|
|
237
|
+
count: rest.reduce((sum, e) => sum + e.count, 0),
|
|
238
|
+
total_amount: rest.reduce((sum, e) => sum + e.total_amount, 0)
|
|
239
|
+
});
|
|
240
|
+
return [...kept, rollup];
|
|
241
|
+
};
|
|
242
|
+
const payeeBreakdown = sortByAbsAmount([...byPayee.values()]);
|
|
243
|
+
const categoryBreakdown = sortByAbsAmount([...byCategory.values()]);
|
|
244
|
+
return {
|
|
245
|
+
total_count: transactions.length,
|
|
246
|
+
total_amount: totalAmount,
|
|
247
|
+
date_range: dates.length > 0 ? {
|
|
248
|
+
from: dates.reduce((a, b) => a < b ? a : b),
|
|
249
|
+
to: dates.reduce((a, b) => a > b ? a : b)
|
|
250
|
+
} : null,
|
|
251
|
+
by_payee: truncate(payeeBreakdown, (e) => ({ payee_id: null, payee_name: "(other)", ...e })),
|
|
252
|
+
by_category: truncate(categoryBreakdown, (e) => ({ category_id: null, category_name: "(other)", ...e })),
|
|
253
|
+
by_cleared_status: [...byCleared.entries()].map(([status, entry]) => ({ status, ...entry })),
|
|
254
|
+
by_approval_status: [...byApproval.entries()].map(([approved, entry]) => ({ approved: approved === "true", ...entry }))
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function findTransferCandidates(source, allTransactions, options) {
|
|
258
|
+
const sourceAmount = Math.abs(source.amount);
|
|
259
|
+
const sourceDateMs = new Date(source.date).getTime();
|
|
260
|
+
const msPerDay = 864e5;
|
|
261
|
+
function daysBetween(t) {
|
|
262
|
+
return Math.abs(new Date(t.date).getTime() - sourceDateMs) / msPerDay;
|
|
263
|
+
}
|
|
264
|
+
return allTransactions.filter((t) => {
|
|
265
|
+
if (t.account_id === source.account_id) return false;
|
|
266
|
+
if (Math.abs(t.amount) !== sourceAmount) return false;
|
|
267
|
+
if (Math.sign(t.amount) === Math.sign(source.amount)) return false;
|
|
268
|
+
return daysBetween(t) <= options.maxDays;
|
|
269
|
+
}).map((t) => ({
|
|
270
|
+
transaction: t,
|
|
271
|
+
already_linked: !!t.transfer_transaction_id,
|
|
272
|
+
date_difference_days: Math.round(daysBetween(t)),
|
|
273
|
+
has_transfer_payee: !!t.payee_name?.startsWith("Transfer :")
|
|
274
|
+
})).sort((a, b) => a.date_difference_days - b.date_difference_days);
|
|
275
|
+
}
|
|
190
276
|
function applyFieldSelection(items, fields) {
|
|
191
277
|
if (!fields) return items;
|
|
192
278
|
const fieldList = fields.split(",").map((f) => f.trim());
|
|
@@ -216,6 +302,7 @@ function outputJson(data, options = {}) {
|
|
|
216
302
|
|
|
217
303
|
// src/commands/auth.ts
|
|
218
304
|
import { Command } from "commander";
|
|
305
|
+
import { createInterface } from "readline";
|
|
219
306
|
|
|
220
307
|
// src/lib/auth.ts
|
|
221
308
|
import { Entry } from "@napi-rs/keyring";
|
|
@@ -593,6 +680,18 @@ var YnabClient = class {
|
|
|
593
680
|
return response.data.transaction;
|
|
594
681
|
});
|
|
595
682
|
}
|
|
683
|
+
async updateTransactions(transactions, budgetId) {
|
|
684
|
+
return this.withErrorHandling(async () => {
|
|
685
|
+
const api = await this.getApi();
|
|
686
|
+
const id = await this.getBudgetId(budgetId);
|
|
687
|
+
const response = await api.transactions.updateTransactions(id, transactions);
|
|
688
|
+
return {
|
|
689
|
+
transactions: response.data.transactions,
|
|
690
|
+
transaction_ids: response.data.transaction_ids,
|
|
691
|
+
server_knowledge: response.data.server_knowledge
|
|
692
|
+
};
|
|
693
|
+
});
|
|
694
|
+
}
|
|
596
695
|
async deleteTransaction(transactionId, budgetId) {
|
|
597
696
|
return this.withErrorHandling(async () => {
|
|
598
697
|
const api = await this.getApi();
|
|
@@ -705,11 +804,42 @@ function buildUpdateObject(options, mapping) {
|
|
|
705
804
|
}
|
|
706
805
|
|
|
707
806
|
// src/commands/auth.ts
|
|
807
|
+
function readTokenFromStdin() {
|
|
808
|
+
return new Promise((resolve, reject) => {
|
|
809
|
+
let data = "";
|
|
810
|
+
process.stdin.setEncoding("utf8");
|
|
811
|
+
process.stdin.on("data", (chunk) => {
|
|
812
|
+
data += chunk;
|
|
813
|
+
});
|
|
814
|
+
process.stdin.on("end", () => resolve(data.trim()));
|
|
815
|
+
process.stdin.on("error", reject);
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
function promptForToken() {
|
|
819
|
+
return new Promise((resolve) => {
|
|
820
|
+
const rl = createInterface({
|
|
821
|
+
input: process.stdin,
|
|
822
|
+
output: process.stderr
|
|
823
|
+
});
|
|
824
|
+
process.stderr.write("Enter YNAB Personal Access Token: ");
|
|
825
|
+
rl.question("", (answer) => {
|
|
826
|
+
rl.close();
|
|
827
|
+
resolve(answer.trim());
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
}
|
|
708
831
|
function createAuthCommand() {
|
|
709
832
|
const cmd = new Command("auth").description("Authentication management");
|
|
710
|
-
cmd.command("login").description("Configure access token").
|
|
833
|
+
cmd.command("login").description("Configure access token").option("-t, --token <token>", "YNAB Personal Access Token").action(
|
|
711
834
|
withErrorHandling(async (options) => {
|
|
712
|
-
|
|
835
|
+
let token;
|
|
836
|
+
if (options.token) {
|
|
837
|
+
token = options.token.trim();
|
|
838
|
+
} else if (!process.stdin.isTTY) {
|
|
839
|
+
token = await readTokenFromStdin();
|
|
840
|
+
} else {
|
|
841
|
+
token = await promptForToken();
|
|
842
|
+
}
|
|
713
843
|
if (!token) {
|
|
714
844
|
throw new YnabCliError("Access token cannot be empty", 400);
|
|
715
845
|
}
|
|
@@ -836,7 +966,10 @@ function createAccountsCommand() {
|
|
|
836
966
|
outputJson(account);
|
|
837
967
|
})
|
|
838
968
|
);
|
|
839
|
-
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").option("--type <type>", "Filter by transaction type").
|
|
969
|
+
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").option("--type <type>", "Filter by transaction type").option(
|
|
970
|
+
"--fields <fields>",
|
|
971
|
+
"Comma-separated list of fields to include (e.g., id,date,amount,memo)"
|
|
972
|
+
).action(
|
|
840
973
|
withErrorHandling(
|
|
841
974
|
async (id, options) => {
|
|
842
975
|
const result = await client.getTransactionsByAccount(id, {
|
|
@@ -844,7 +977,8 @@ function createAccountsCommand() {
|
|
|
844
977
|
sinceDate: options.since ? parseDate(options.since) : void 0,
|
|
845
978
|
type: options.type
|
|
846
979
|
});
|
|
847
|
-
|
|
980
|
+
const transactions = result?.transactions || [];
|
|
981
|
+
outputJson(applyFieldSelection(transactions, options.fields));
|
|
848
982
|
}
|
|
849
983
|
)
|
|
850
984
|
);
|
|
@@ -916,7 +1050,10 @@ function createCategoriesCommand() {
|
|
|
916
1050
|
}
|
|
917
1051
|
)
|
|
918
1052
|
);
|
|
919
|
-
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").option("--type <type>", "Filter by transaction type").option("--last-knowledge <number>", "Last knowledge of server", parseInt).
|
|
1053
|
+
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").option("--type <type>", "Filter by transaction type").option("--last-knowledge <number>", "Last knowledge of server", parseInt).option(
|
|
1054
|
+
"--fields <fields>",
|
|
1055
|
+
"Comma-separated list of fields to include (e.g., id,date,amount,memo)"
|
|
1056
|
+
).action(
|
|
920
1057
|
withErrorHandling(
|
|
921
1058
|
async (id, options) => {
|
|
922
1059
|
const result = await client.getTransactionsByCategory(id, {
|
|
@@ -925,7 +1062,8 @@ function createCategoriesCommand() {
|
|
|
925
1062
|
type: options.type,
|
|
926
1063
|
lastKnowledgeOfServer: options.lastKnowledge
|
|
927
1064
|
});
|
|
928
|
-
|
|
1065
|
+
const transactions = result?.transactions || [];
|
|
1066
|
+
outputJson(applyFieldSelection(transactions, options.fields));
|
|
929
1067
|
}
|
|
930
1068
|
)
|
|
931
1069
|
);
|
|
@@ -934,6 +1072,7 @@ function createCategoriesCommand() {
|
|
|
934
1072
|
|
|
935
1073
|
// src/commands/transactions.ts
|
|
936
1074
|
import { Command as Command6 } from "commander";
|
|
1075
|
+
import dayjs2 from "dayjs";
|
|
937
1076
|
|
|
938
1077
|
// src/lib/schemas.ts
|
|
939
1078
|
function validateTransactionSplits(data) {
|
|
@@ -956,6 +1095,44 @@ function validateTransactionSplits(data) {
|
|
|
956
1095
|
};
|
|
957
1096
|
});
|
|
958
1097
|
}
|
|
1098
|
+
var BATCH_UPDATE_FIELDS = [
|
|
1099
|
+
"id",
|
|
1100
|
+
"import_id",
|
|
1101
|
+
"account_id",
|
|
1102
|
+
"date",
|
|
1103
|
+
"amount",
|
|
1104
|
+
"payee_id",
|
|
1105
|
+
"payee_name",
|
|
1106
|
+
"category_id",
|
|
1107
|
+
"memo",
|
|
1108
|
+
"cleared",
|
|
1109
|
+
"approved",
|
|
1110
|
+
"flag_color"
|
|
1111
|
+
];
|
|
1112
|
+
function validateBatchUpdates(data) {
|
|
1113
|
+
if (!Array.isArray(data)) {
|
|
1114
|
+
throw new YnabCliError("Batch updates must be an array", 400);
|
|
1115
|
+
}
|
|
1116
|
+
return data.map((item, index) => {
|
|
1117
|
+
if (typeof item !== "object" || item === null) {
|
|
1118
|
+
throw new YnabCliError(`Update at index ${index} must be an object`, 400);
|
|
1119
|
+
}
|
|
1120
|
+
const update = item;
|
|
1121
|
+
if (!update.id && !update.import_id) {
|
|
1122
|
+
throw new YnabCliError(
|
|
1123
|
+
`Update at index ${index} must have either "id" or "import_id"`,
|
|
1124
|
+
400
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
const result = {};
|
|
1128
|
+
for (const field of BATCH_UPDATE_FIELDS) {
|
|
1129
|
+
if (update[field] !== void 0) {
|
|
1130
|
+
result[field] = update[field];
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return result;
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
959
1136
|
function validateApiData(data) {
|
|
960
1137
|
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
961
1138
|
throw new YnabCliError("API data must be an object", 400);
|
|
@@ -964,6 +1141,18 @@ function validateApiData(data) {
|
|
|
964
1141
|
}
|
|
965
1142
|
|
|
966
1143
|
// src/commands/transactions.ts
|
|
1144
|
+
async function fetchTransactions(options) {
|
|
1145
|
+
const params = {
|
|
1146
|
+
budgetId: options.budget,
|
|
1147
|
+
sinceDate: options.since ? parseDate(options.since) : void 0,
|
|
1148
|
+
type: options.type,
|
|
1149
|
+
lastKnowledgeOfServer: options.lastKnowledge
|
|
1150
|
+
};
|
|
1151
|
+
if (options.account) return client.getTransactionsByAccount(options.account, params);
|
|
1152
|
+
if (options.category) return client.getTransactionsByCategory(options.category, params);
|
|
1153
|
+
if (options.payee) return client.getTransactionsByPayee(options.payee, params);
|
|
1154
|
+
return client.getTransactions(params);
|
|
1155
|
+
}
|
|
967
1156
|
function buildTransactionData(options) {
|
|
968
1157
|
if (!options.account) {
|
|
969
1158
|
throw new YnabCliError("--account is required in non-interactive mode", 400);
|
|
@@ -991,25 +1180,27 @@ function createTransactionsCommand() {
|
|
|
991
1180
|
).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(
|
|
992
1181
|
"--fields <fields>",
|
|
993
1182
|
"Comma-separated list of fields to include (e.g., id,date,amount,memo)"
|
|
994
|
-
).action(
|
|
1183
|
+
).option("--last-knowledge <number>", "Last server knowledge for delta requests. When used, output includes server_knowledge.", parseInt).option("--limit <number>", "Maximum number of transactions to return", parseInt).action(
|
|
995
1184
|
withErrorHandling(
|
|
996
1185
|
async (options) => {
|
|
997
|
-
const
|
|
998
|
-
budgetId: options.budget,
|
|
999
|
-
sinceDate: options.since ? parseDate(options.since) : void 0,
|
|
1000
|
-
type: options.type
|
|
1001
|
-
};
|
|
1002
|
-
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);
|
|
1186
|
+
const result = await fetchTransactions(options);
|
|
1003
1187
|
const transactions = result?.transactions || [];
|
|
1004
|
-
|
|
1188
|
+
let filtered = applyTransactionFilters(transactions, {
|
|
1005
1189
|
until: options.until ? parseDate(options.until) : void 0,
|
|
1006
1190
|
approved: options.approved,
|
|
1007
1191
|
status: options.status,
|
|
1008
1192
|
minAmount: options.minAmount,
|
|
1009
1193
|
maxAmount: options.maxAmount
|
|
1010
1194
|
});
|
|
1195
|
+
if (options.limit && options.limit > 0) {
|
|
1196
|
+
filtered = filtered.slice(0, options.limit);
|
|
1197
|
+
}
|
|
1011
1198
|
const selected = applyFieldSelection(filtered, options.fields);
|
|
1012
|
-
|
|
1199
|
+
if (options.lastKnowledge !== void 0) {
|
|
1200
|
+
outputJson({ transactions: selected, server_knowledge: result?.server_knowledge });
|
|
1201
|
+
} else {
|
|
1202
|
+
outputJson(selected);
|
|
1203
|
+
}
|
|
1013
1204
|
}
|
|
1014
1205
|
)
|
|
1015
1206
|
);
|
|
@@ -1135,6 +1326,33 @@ function createTransactionsCommand() {
|
|
|
1135
1326
|
}
|
|
1136
1327
|
)
|
|
1137
1328
|
);
|
|
1329
|
+
cmd.command("batch-update").description(
|
|
1330
|
+
"Update multiple transactions in a single API call. Amounts should be in dollars (e.g., -21.40)."
|
|
1331
|
+
).requiredOption(
|
|
1332
|
+
"--transactions <json>",
|
|
1333
|
+
'JSON array of transaction updates. Each must have "id" or "import_id". Example: [{"id": "tx1", "approved": true, "category_id": "cat1"}]'
|
|
1334
|
+
).option("-b, --budget <id>", "Budget ID").action(
|
|
1335
|
+
withErrorHandling(
|
|
1336
|
+
async (options) => {
|
|
1337
|
+
let parsed;
|
|
1338
|
+
try {
|
|
1339
|
+
parsed = JSON.parse(options.transactions);
|
|
1340
|
+
} catch {
|
|
1341
|
+
throw new YnabCliError("Invalid JSON in --transactions parameter", 400);
|
|
1342
|
+
}
|
|
1343
|
+
const updates = validateBatchUpdates(parsed);
|
|
1344
|
+
const transactionsInMilliunits = updates.map((update) => ({
|
|
1345
|
+
...update,
|
|
1346
|
+
...update.amount !== void 0 ? { amount: amountToMilliunits(update.amount) } : {}
|
|
1347
|
+
}));
|
|
1348
|
+
const result = await client.updateTransactions(
|
|
1349
|
+
{ transactions: transactionsInMilliunits },
|
|
1350
|
+
options.budget
|
|
1351
|
+
);
|
|
1352
|
+
outputJson(result);
|
|
1353
|
+
}
|
|
1354
|
+
)
|
|
1355
|
+
);
|
|
1138
1356
|
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").option("--until <date>", "Search transactions until date").option("--approved <value>", "Filter by approval status: true or false").option(
|
|
1139
1357
|
"--status <statuses>",
|
|
1140
1358
|
"Filter by cleared status: cleared, uncleared, reconciled (comma-separated)"
|
|
@@ -1177,6 +1395,49 @@ function createTransactionsCommand() {
|
|
|
1177
1395
|
}
|
|
1178
1396
|
)
|
|
1179
1397
|
);
|
|
1398
|
+
cmd.command("summary").description("Summarize transactions with aggregate counts by payee, category, and status").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").option("--until <date>", "Filter transactions until date").option("--type <type>", "Filter by transaction type").option("--approved <value>", "Filter by approval status: true or false").option(
|
|
1399
|
+
"--status <statuses>",
|
|
1400
|
+
"Filter by cleared status: cleared, uncleared, reconciled (comma-separated)"
|
|
1401
|
+
).option("--min-amount <amount>", "Minimum amount in currency units", parseFloat).option("--max-amount <amount>", "Maximum amount in currency units", parseFloat).option("--top <number>", "Limit payee/category breakdowns to top N entries", parseInt).action(
|
|
1402
|
+
withErrorHandling(
|
|
1403
|
+
async (options) => {
|
|
1404
|
+
const result = await fetchTransactions(options);
|
|
1405
|
+
const transactions = result?.transactions || [];
|
|
1406
|
+
const filtered = applyTransactionFilters(transactions, {
|
|
1407
|
+
until: options.until ? parseDate(options.until) : void 0,
|
|
1408
|
+
approved: options.approved,
|
|
1409
|
+
status: options.status,
|
|
1410
|
+
minAmount: options.minAmount,
|
|
1411
|
+
maxAmount: options.maxAmount
|
|
1412
|
+
});
|
|
1413
|
+
const summary = summarizeTransactions(
|
|
1414
|
+
filtered,
|
|
1415
|
+
options.top ? { top: options.top } : void 0
|
|
1416
|
+
);
|
|
1417
|
+
outputJson(summary);
|
|
1418
|
+
}
|
|
1419
|
+
)
|
|
1420
|
+
);
|
|
1421
|
+
cmd.command("find-transfers").description("Find candidate transfer matches for a transaction across accounts").argument("<id>", "Transaction ID").option("-b, --budget <id>", "Budget ID").option("--days <number>", "Maximum date difference in days (default: 3)", parseInt).option("--since <date>", "Search transactions since date (defaults to source date minus --days)").action(
|
|
1422
|
+
withErrorHandling(
|
|
1423
|
+
async (id, options) => {
|
|
1424
|
+
const maxDays = options.days ?? 3;
|
|
1425
|
+
const source = await client.getTransaction(id, options.budget);
|
|
1426
|
+
const sinceDate = options.since ? parseDate(options.since) : dayjs2(source.date).subtract(maxDays, "day").format("YYYY-MM-DD");
|
|
1427
|
+
const result = await client.getTransactions({
|
|
1428
|
+
budgetId: options.budget,
|
|
1429
|
+
sinceDate
|
|
1430
|
+
});
|
|
1431
|
+
const allTransactions = result?.transactions || [];
|
|
1432
|
+
const candidates = findTransferCandidates(
|
|
1433
|
+
source,
|
|
1434
|
+
allTransactions,
|
|
1435
|
+
{ maxDays }
|
|
1436
|
+
);
|
|
1437
|
+
outputJson({ source, candidates });
|
|
1438
|
+
}
|
|
1439
|
+
)
|
|
1440
|
+
);
|
|
1180
1441
|
return cmd;
|
|
1181
1442
|
}
|
|
1182
1443
|
|
|
@@ -1255,7 +1516,10 @@ function createPayeesCommand() {
|
|
|
1255
1516
|
outputJson(locations);
|
|
1256
1517
|
})
|
|
1257
1518
|
);
|
|
1258
|
-
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").option("--type <type>", "Filter by transaction type").option("--last-knowledge <number>", "Last knowledge of server", parseInt).
|
|
1519
|
+
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").option("--type <type>", "Filter by transaction type").option("--last-knowledge <number>", "Last knowledge of server", parseInt).option(
|
|
1520
|
+
"--fields <fields>",
|
|
1521
|
+
"Comma-separated list of fields to include (e.g., id,date,amount,memo)"
|
|
1522
|
+
).action(
|
|
1259
1523
|
withErrorHandling(
|
|
1260
1524
|
async (id, options) => {
|
|
1261
1525
|
const result = await client.getTransactionsByPayee(id, {
|
|
@@ -1264,7 +1528,8 @@ function createPayeesCommand() {
|
|
|
1264
1528
|
type: options.type,
|
|
1265
1529
|
lastKnowledgeOfServer: options.lastKnowledge
|
|
1266
1530
|
});
|
|
1267
|
-
|
|
1531
|
+
const transactions = result?.transactions || [];
|
|
1532
|
+
outputJson(applyFieldSelection(transactions, options.fields));
|
|
1268
1533
|
}
|
|
1269
1534
|
)
|
|
1270
1535
|
);
|
|
@@ -1348,6 +1613,9 @@ var toolRegistry = [
|
|
|
1348
1613
|
{ name: "update_transaction", description: "Update an existing transaction" },
|
|
1349
1614
|
{ name: "delete_transaction", description: "Delete a transaction" },
|
|
1350
1615
|
{ name: "import_transactions", description: "Trigger import of linked bank transactions" },
|
|
1616
|
+
{ name: "batch_update_transactions", description: "Update multiple transactions in a single API call" },
|
|
1617
|
+
{ name: "summarize_transactions", description: "Get aggregate summary of transactions by payee, category, and status" },
|
|
1618
|
+
{ name: "find_transfer_candidates", description: "Find candidate transfer matches for a transaction across accounts" },
|
|
1351
1619
|
{ name: "list_transactions_by_account", description: "List transactions for a specific account" },
|
|
1352
1620
|
{ name: "list_transactions_by_category", description: "List transactions for a specific category" },
|
|
1353
1621
|
{ name: "list_transactions_by_payee", description: "List transactions for a specific payee" },
|
|
@@ -1460,9 +1728,18 @@ server.tool(
|
|
|
1460
1728
|
{
|
|
1461
1729
|
budgetId: z.string().optional().describe("Budget ID (uses default if not specified)"),
|
|
1462
1730
|
sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
|
|
1463
|
-
type: z.enum(["uncategorized", "unapproved"]).optional().describe("Filter by transaction type")
|
|
1731
|
+
type: z.enum(["uncategorized", "unapproved"]).optional().describe("Filter by transaction type"),
|
|
1732
|
+
lastKnowledgeOfServer: z.number().optional().describe("Delta sync: only return changes since this server knowledge value. Response includes server_knowledge for next call."),
|
|
1733
|
+
limit: z.number().optional().describe("Maximum number of transactions to return"),
|
|
1734
|
+
fields: z.string().optional().describe("Comma-separated list of fields to include (e.g., id,date,amount,memo)")
|
|
1464
1735
|
},
|
|
1465
|
-
async ({ budgetId, sinceDate, type
|
|
1736
|
+
async ({ budgetId, sinceDate, type, lastKnowledgeOfServer, limit, fields }) => {
|
|
1737
|
+
const result = await client.getTransactions({ budgetId, sinceDate, type, lastKnowledgeOfServer });
|
|
1738
|
+
let transactions = result?.transactions || [];
|
|
1739
|
+
if (limit && limit > 0) transactions = transactions.slice(0, limit);
|
|
1740
|
+
const selected = fields ? applyFieldSelection(transactions, fields) : transactions;
|
|
1741
|
+
return currencyResponse({ transactions: selected, server_knowledge: result?.server_knowledge });
|
|
1742
|
+
}
|
|
1466
1743
|
);
|
|
1467
1744
|
server.tool(
|
|
1468
1745
|
"get_transaction",
|
|
@@ -1548,15 +1825,111 @@ server.tool(
|
|
|
1548
1825
|
{ budgetId: z.string().optional().describe("Budget ID (uses default if not specified)") },
|
|
1549
1826
|
async ({ budgetId }) => jsonResponse(await client.importTransactions(budgetId))
|
|
1550
1827
|
);
|
|
1828
|
+
server.tool(
|
|
1829
|
+
"batch_update_transactions",
|
|
1830
|
+
"Update multiple transactions in a single API call. Amounts in dollars.",
|
|
1831
|
+
{
|
|
1832
|
+
transactions: z.array(z.object({
|
|
1833
|
+
id: z.string().optional().nullable().describe("Transaction ID (required if no import_id)"),
|
|
1834
|
+
import_id: z.string().optional().nullable().describe("Import ID (required if no id)"),
|
|
1835
|
+
account_id: z.string().optional().describe("Account ID"),
|
|
1836
|
+
date: z.string().optional().describe("Transaction date (YYYY-MM-DD)"),
|
|
1837
|
+
amount: z.number().optional().describe("Amount in dollars (negative for outflow)"),
|
|
1838
|
+
payee_id: z.string().optional().nullable().describe("Payee ID"),
|
|
1839
|
+
payee_name: z.string().optional().nullable().describe("Payee name"),
|
|
1840
|
+
category_id: z.string().optional().nullable().describe("Category ID"),
|
|
1841
|
+
memo: z.string().optional().nullable().describe("Transaction memo"),
|
|
1842
|
+
cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
|
|
1843
|
+
approved: z.boolean().optional().describe("Whether the transaction is approved"),
|
|
1844
|
+
flag_color: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).optional().nullable().describe("Flag color")
|
|
1845
|
+
})).describe("Array of transaction updates"),
|
|
1846
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not specified)")
|
|
1847
|
+
},
|
|
1848
|
+
async ({ transactions, budgetId }) => {
|
|
1849
|
+
const transactionsInMilliunits = transactions.map((update) => ({
|
|
1850
|
+
...update,
|
|
1851
|
+
...update.amount !== void 0 ? { amount: amountToMilliunits(update.amount) } : {}
|
|
1852
|
+
}));
|
|
1853
|
+
return currencyResponse(
|
|
1854
|
+
await client.updateTransactions(
|
|
1855
|
+
{ transactions: transactionsInMilliunits },
|
|
1856
|
+
budgetId
|
|
1857
|
+
)
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1860
|
+
);
|
|
1861
|
+
server.tool(
|
|
1862
|
+
"summarize_transactions",
|
|
1863
|
+
"Get aggregate summary of transactions by payee, category, and status",
|
|
1864
|
+
{
|
|
1865
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not specified)"),
|
|
1866
|
+
sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
|
|
1867
|
+
untilDate: z.string().optional().describe("Only return transactions on or before this date (YYYY-MM-DD)"),
|
|
1868
|
+
type: z.enum(["uncategorized", "unapproved"]).optional().describe("Filter by transaction type"),
|
|
1869
|
+
approved: z.enum(["true", "false"]).optional().describe("Filter by approval status"),
|
|
1870
|
+
status: z.string().optional().describe("Filter by cleared status: cleared, uncleared, reconciled (comma-separated)"),
|
|
1871
|
+
minAmount: z.number().optional().describe("Minimum amount in dollars"),
|
|
1872
|
+
maxAmount: z.number().optional().describe("Maximum amount in dollars"),
|
|
1873
|
+
top: z.number().optional().describe("Limit payee/category breakdowns to top N entries")
|
|
1874
|
+
},
|
|
1875
|
+
async ({ budgetId, sinceDate, untilDate, type, approved, status, minAmount, maxAmount, top }) => {
|
|
1876
|
+
const result = await client.getTransactions({ budgetId, sinceDate, type });
|
|
1877
|
+
const transactions = result?.transactions || [];
|
|
1878
|
+
const filtered = applyTransactionFilters(transactions, {
|
|
1879
|
+
until: untilDate,
|
|
1880
|
+
approved,
|
|
1881
|
+
status,
|
|
1882
|
+
minAmount,
|
|
1883
|
+
maxAmount
|
|
1884
|
+
});
|
|
1885
|
+
const summary = summarizeTransactions(
|
|
1886
|
+
filtered,
|
|
1887
|
+
top ? { top } : void 0
|
|
1888
|
+
);
|
|
1889
|
+
return currencyResponse(summary);
|
|
1890
|
+
}
|
|
1891
|
+
);
|
|
1892
|
+
server.tool(
|
|
1893
|
+
"find_transfer_candidates",
|
|
1894
|
+
"Find candidate transfer matches for a transaction across accounts",
|
|
1895
|
+
{
|
|
1896
|
+
transactionId: z.string().describe("Transaction ID to find transfers for"),
|
|
1897
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not specified)"),
|
|
1898
|
+
maxDays: z.number().optional().describe("Maximum date difference in days (default: 3)"),
|
|
1899
|
+
sinceDate: z.string().optional().describe("Search transactions since date (defaults to source date minus maxDays)")
|
|
1900
|
+
},
|
|
1901
|
+
async ({ transactionId, budgetId, maxDays: maxDaysParam, sinceDate }) => {
|
|
1902
|
+
const days = maxDaysParam ?? 3;
|
|
1903
|
+
const source = await client.getTransaction(transactionId, budgetId);
|
|
1904
|
+
if (!sinceDate) {
|
|
1905
|
+
const d = new Date(source.date);
|
|
1906
|
+
d.setDate(d.getDate() - days);
|
|
1907
|
+
sinceDate = d.toISOString().split("T")[0];
|
|
1908
|
+
}
|
|
1909
|
+
const result = await client.getTransactions({ budgetId, sinceDate });
|
|
1910
|
+
const allTransactions = result?.transactions || [];
|
|
1911
|
+
const candidates = findTransferCandidates(
|
|
1912
|
+
source,
|
|
1913
|
+
allTransactions,
|
|
1914
|
+
{ maxDays: days }
|
|
1915
|
+
);
|
|
1916
|
+
return currencyResponse({ source, candidates });
|
|
1917
|
+
}
|
|
1918
|
+
);
|
|
1551
1919
|
server.tool(
|
|
1552
1920
|
"list_transactions_by_account",
|
|
1553
1921
|
"List transactions for a specific account",
|
|
1554
1922
|
{
|
|
1555
1923
|
accountId: z.string().describe("Account ID"),
|
|
1556
1924
|
budgetId: z.string().optional().describe("Budget ID (uses default if not specified)"),
|
|
1557
|
-
sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)")
|
|
1925
|
+
sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
|
|
1926
|
+
fields: z.string().optional().describe("Comma-separated list of fields to include (e.g., id,date,amount,memo)")
|
|
1558
1927
|
},
|
|
1559
|
-
async ({ accountId, budgetId, sinceDate }) =>
|
|
1928
|
+
async ({ accountId, budgetId, sinceDate, fields }) => {
|
|
1929
|
+
const result = await client.getTransactionsByAccount(accountId, { budgetId, sinceDate });
|
|
1930
|
+
if (!fields) return currencyResponse(result);
|
|
1931
|
+
return currencyResponse(applyFieldSelection(result?.transactions || [], fields));
|
|
1932
|
+
}
|
|
1560
1933
|
);
|
|
1561
1934
|
server.tool(
|
|
1562
1935
|
"list_transactions_by_category",
|
|
@@ -1564,9 +1937,14 @@ server.tool(
|
|
|
1564
1937
|
{
|
|
1565
1938
|
categoryId: z.string().describe("Category ID"),
|
|
1566
1939
|
budgetId: z.string().optional().describe("Budget ID (uses default if not specified)"),
|
|
1567
|
-
sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)")
|
|
1940
|
+
sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
|
|
1941
|
+
fields: z.string().optional().describe("Comma-separated list of fields to include (e.g., id,date,amount,memo)")
|
|
1568
1942
|
},
|
|
1569
|
-
async ({ categoryId, budgetId, sinceDate }) =>
|
|
1943
|
+
async ({ categoryId, budgetId, sinceDate, fields }) => {
|
|
1944
|
+
const result = await client.getTransactionsByCategory(categoryId, { budgetId, sinceDate });
|
|
1945
|
+
if (!fields) return currencyResponse(result);
|
|
1946
|
+
return currencyResponse(applyFieldSelection(result?.transactions || [], fields));
|
|
1947
|
+
}
|
|
1570
1948
|
);
|
|
1571
1949
|
server.tool(
|
|
1572
1950
|
"list_transactions_by_payee",
|
|
@@ -1574,9 +1952,14 @@ server.tool(
|
|
|
1574
1952
|
{
|
|
1575
1953
|
payeeId: z.string().describe("Payee ID"),
|
|
1576
1954
|
budgetId: z.string().optional().describe("Budget ID (uses default if not specified)"),
|
|
1577
|
-
sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)")
|
|
1955
|
+
sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
|
|
1956
|
+
fields: z.string().optional().describe("Comma-separated list of fields to include (e.g., id,date,amount,memo)")
|
|
1578
1957
|
},
|
|
1579
|
-
async ({ payeeId, budgetId, sinceDate }) =>
|
|
1958
|
+
async ({ payeeId, budgetId, sinceDate, fields }) => {
|
|
1959
|
+
const result = await client.getTransactionsByPayee(payeeId, { budgetId, sinceDate });
|
|
1960
|
+
if (!fields) return currencyResponse(result);
|
|
1961
|
+
return currencyResponse(applyFieldSelection(result?.transactions || [], fields));
|
|
1962
|
+
}
|
|
1580
1963
|
);
|
|
1581
1964
|
server.tool(
|
|
1582
1965
|
"list_payees",
|
|
@@ -1697,7 +2080,7 @@ function createMcpCommand() {
|
|
|
1697
2080
|
|
|
1698
2081
|
// src/cli.ts
|
|
1699
2082
|
var program = new Command12();
|
|
1700
|
-
program.name("ynab").description("A command-line interface for You Need a Budget (YNAB)").version("2.
|
|
2083
|
+
program.name("ynab").description("A command-line interface for You Need a Budget (YNAB)").version("2.8.0").option("-c, --compact", "Minified JSON output (single line)").hook("preAction", (thisCommand) => {
|
|
1701
2084
|
const options = thisCommand.opts();
|
|
1702
2085
|
setOutputOptions({
|
|
1703
2086
|
compact: options.compact
|