ynab-mcp-deluxe 0.1.9

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.
@@ -0,0 +1,1248 @@
1
+ /**
2
+ * YNAB API client wrapper with caching and enrichment
3
+ */
4
+ import { api as YnabApi, TransactionClearedStatus, utils, } from 'ynab';
5
+ import { checkForDrift, isAlwaysFullSyncEnabled, isDriftDetectionEnabled, logDriftCheckResult, recordDriftCheck, shouldPerformDriftCheck, } from './drift-detection.js';
6
+ import { saveDriftSnapshot, shouldSampleDrift } from './drift-snapshot.js';
7
+ import { buildLocalBudget, mergeDelta } from './local-budget.js';
8
+ import { createCombinedLogger, fileLogger } from './logger.js';
9
+ import { persistSyncResponse } from './sync-history.js';
10
+ import { ApiSyncProvider } from './sync-providers.js';
11
+ // ============================================================================
12
+ // Read-Only Mode Support
13
+ // ============================================================================
14
+ /**
15
+ * Check if the server is running in read-only mode
16
+ */
17
+ export function isReadOnlyMode() {
18
+ const value = process.env['YNAB_READ_ONLY'];
19
+ return value === 'true' || value === '1';
20
+ }
21
+ /**
22
+ * Assert that write operations are allowed
23
+ * @throws Error if read-only mode is enabled
24
+ */
25
+ export function assertWriteAllowed(operation) {
26
+ if (isReadOnlyMode()) {
27
+ throw new Error(`Write operation "${operation}" blocked: Server is in read-only mode. ` +
28
+ `Set YNAB_READ_ONLY=false to enable writes.`);
29
+ }
30
+ }
31
+ // ============================================================================
32
+ // Sync Configuration
33
+ // ============================================================================
34
+ // Enrichment Helpers
35
+ // ============================================================================
36
+ //
37
+ // These helpers handle name resolution for transactions and subtransactions.
38
+ //
39
+ // Why we check for existing names (not dead code):
40
+ // - The YNAB SDK types for SubTransaction and ScheduledSubTransaction include
41
+ // optional payee_name and category_name fields
42
+ // - The FULL BUDGET endpoint (/budgets/{id}) returns TransactionSummary which
43
+ // may NOT populate these fields (only IDs are guaranteed)
44
+ // - INDIVIDUAL transaction endpoints DO populate these name fields
45
+ // - We defensively check for existing names first, then fall back to ID lookup
46
+ // This ensures we work correctly regardless of which endpoint provided the data
47
+ // ============================================================================
48
+ /**
49
+ * Resolve a payee name from a payee ID using the LocalBudget lookup maps.
50
+ * Returns the existing name if already provided, otherwise looks up by ID.
51
+ *
52
+ * @param payeeId - The payee ID to look up
53
+ * @param existingName - Pre-populated name (may be present from individual tx endpoints)
54
+ * @param localBudget - LocalBudget with payee lookup maps
55
+ */
56
+ function resolvePayeeName(payeeId, existingName, localBudget) {
57
+ if (existingName !== undefined &&
58
+ existingName !== null &&
59
+ existingName !== '')
60
+ return existingName;
61
+ if (payeeId === undefined || payeeId === null)
62
+ return null;
63
+ return localBudget.payeeById.get(payeeId)?.name ?? null;
64
+ }
65
+ /**
66
+ * Resolve category name and category group name from a category ID.
67
+ * Returns the existing names if already provided, otherwise looks up by ID.
68
+ *
69
+ * @param categoryId - The category ID to look up
70
+ * @param existingName - Pre-populated name (may be present from individual tx endpoints)
71
+ * @param localBudget - LocalBudget with category lookup maps
72
+ */
73
+ function resolveCategoryInfo(categoryId, existingName, localBudget) {
74
+ if (existingName !== undefined &&
75
+ existingName !== null &&
76
+ existingName !== '') {
77
+ // We have the name but still need the group name from lookup
78
+ const category = categoryId !== undefined && categoryId !== null
79
+ ? localBudget.categoryById.get(categoryId)
80
+ : undefined;
81
+ const categoryGroupName = category !== undefined
82
+ ? (localBudget.categoryGroupNameById.get(category.category_group_id) ??
83
+ null)
84
+ : null;
85
+ return { categoryGroupName, categoryName: existingName };
86
+ }
87
+ if (categoryId === undefined || categoryId === null) {
88
+ return { categoryGroupName: null, categoryName: null };
89
+ }
90
+ const category = localBudget.categoryById.get(categoryId);
91
+ if (category === undefined) {
92
+ return { categoryGroupName: null, categoryName: null };
93
+ }
94
+ const categoryGroupName = localBudget.categoryGroupNameById.get(category.category_group_id) ?? null;
95
+ return { categoryGroupName, categoryName: category.name };
96
+ }
97
+ // ============================================================================
98
+ /**
99
+ * Default sync interval in seconds (10 minutes)
100
+ */
101
+ const DEFAULT_SYNC_INTERVAL_SECONDS = 600;
102
+ /**
103
+ * Get the configured sync interval in milliseconds.
104
+ * Configured via YNAB_SYNC_INTERVAL_SECONDS environment variable.
105
+ * Default: 600 seconds (10 minutes)
106
+ * Set to 0 to always sync before every operation.
107
+ */
108
+ export function getSyncIntervalMs() {
109
+ const value = process.env['YNAB_SYNC_INTERVAL_SECONDS'];
110
+ if (value === undefined || value === '') {
111
+ return DEFAULT_SYNC_INTERVAL_SECONDS * 1000;
112
+ }
113
+ const parsed = parseInt(value, 10);
114
+ if (isNaN(parsed) || parsed < 0) {
115
+ // Invalid value, use default
116
+ return DEFAULT_SYNC_INTERVAL_SECONDS * 1000;
117
+ }
118
+ return parsed * 1000;
119
+ }
120
+ function analyzeEntityArray(items) {
121
+ if (items === undefined || items.length === 0) {
122
+ return { count: 0, deletedCount: 0, sampleIds: [] };
123
+ }
124
+ const deletedCount = items.filter((item) => item.deleted === true).length;
125
+ const sampleIds = items.slice(0, 3).map((item) => item.id);
126
+ return {
127
+ count: items.length,
128
+ deletedCount,
129
+ sampleIds,
130
+ };
131
+ }
132
+ function analyzeDeltaResponse(deltaBudget) {
133
+ // Helper for months which use 'month' instead of 'id'
134
+ const analyzeMonths = (items) => {
135
+ if (items === undefined || items.length === 0) {
136
+ return { count: 0, deletedCount: 0, sampleIds: [] };
137
+ }
138
+ const deletedCount = items.filter((item) => item.deleted === true).length;
139
+ const sampleIds = items.slice(0, 3).map((item) => item.month);
140
+ return { count: items.length, deletedCount, sampleIds };
141
+ };
142
+ return {
143
+ accounts: analyzeEntityArray(deltaBudget.accounts),
144
+ categories: analyzeEntityArray(deltaBudget.categories),
145
+ months: analyzeMonths(deltaBudget.months),
146
+ payees: analyzeEntityArray(deltaBudget.payees),
147
+ scheduledTransactions: analyzeEntityArray(deltaBudget.scheduled_transactions),
148
+ transactions: analyzeEntityArray(deltaBudget.transactions),
149
+ };
150
+ }
151
+ /**
152
+ * Default logger when no context is available - logs to file only.
153
+ */
154
+ const defaultLog = fileLogger;
155
+ /**
156
+ * YNAB client with local budget sync
157
+ */
158
+ class YnabClient {
159
+ api = null;
160
+ budgets = null;
161
+ localBudgets = new Map();
162
+ /** Store previous full API responses for drift snapshot collection */
163
+ previousBudgetDetails = new Map();
164
+ lastUsedBudgetId = null;
165
+ syncProvider = null;
166
+ /**
167
+ * Get the YNAB API instance, creating it if necessary
168
+ */
169
+ getApi() {
170
+ if (this.api === null) {
171
+ const token = process.env['YNAB_ACCESS_TOKEN'];
172
+ if (token === undefined || token === '') {
173
+ throw new Error('YNAB authentication failed. Check that YNAB_ACCESS_TOKEN environment variable is set.');
174
+ }
175
+ this.api = new YnabApi(token);
176
+ }
177
+ return this.api;
178
+ }
179
+ /**
180
+ * Get the sync provider, creating it if necessary
181
+ */
182
+ getSyncProvider() {
183
+ if (this.syncProvider === null) {
184
+ const token = process.env['YNAB_ACCESS_TOKEN'];
185
+ if (token === undefined || token === '') {
186
+ throw new Error('YNAB authentication failed. Check that YNAB_ACCESS_TOKEN environment variable is set.');
187
+ }
188
+ this.syncProvider = new ApiSyncProvider(token);
189
+ }
190
+ return this.syncProvider;
191
+ }
192
+ /**
193
+ * Determine what kind of sync is needed based on policy.
194
+ * Returns both the sync type and the reason for logging.
195
+ */
196
+ determineSyncNeeded(localBudget, options) {
197
+ // Force full sync requested
198
+ if (options.forceSync === 'full') {
199
+ return { reason: 'forceSync: full requested', type: 'full' };
200
+ }
201
+ // No local budget yet - need initial full sync
202
+ if (localBudget === undefined) {
203
+ return { reason: 'no local budget exists (initial sync)', type: 'full' };
204
+ }
205
+ // "Always full sync" mode - skip delta optimization entirely
206
+ // This is a valid production strategy if delta sync proves unreliable
207
+ if (isAlwaysFullSyncEnabled()) {
208
+ return { reason: 'YNAB_ALWAYS_FULL_SYNC enabled', type: 'full' };
209
+ }
210
+ // Force delta sync requested
211
+ if (options.forceSync === 'delta') {
212
+ return { reason: 'forceSync: delta requested', type: 'delta' };
213
+ }
214
+ // Write happened - need to sync
215
+ if (localBudget.needsSync) {
216
+ return {
217
+ reason: 'needsSync flag set (write operation occurred)',
218
+ type: 'delta',
219
+ };
220
+ }
221
+ // Check interval
222
+ const elapsed = Date.now() - localBudget.lastSyncedAt.getTime();
223
+ const intervalMs = getSyncIntervalMs();
224
+ if (elapsed >= intervalMs) {
225
+ const elapsedSeconds = Math.round(elapsed / 1000);
226
+ const intervalSeconds = Math.round(intervalMs / 1000);
227
+ return {
228
+ reason: `sync interval passed (${elapsedSeconds}s elapsed, interval: ${intervalSeconds}s)`,
229
+ type: 'delta',
230
+ };
231
+ }
232
+ // Local budget is fresh enough
233
+ const remainingMs = intervalMs - elapsed;
234
+ const remainingSeconds = Math.round(remainingMs / 1000);
235
+ return {
236
+ reason: `local budget is fresh (${remainingSeconds}s until next sync)`,
237
+ type: 'none',
238
+ };
239
+ }
240
+ /**
241
+ * Get a local budget, syncing with YNAB if needed.
242
+ *
243
+ * SYNC STRATEGY (Drift Collection Mode):
244
+ * - Always fetch full budget (guaranteed correct)
245
+ * - Also fetch delta and merge to build "merged" version for comparison
246
+ * - Run drift detection comparing merged vs full
247
+ * - If drift detected (at sample rate), save artifacts for later analysis
248
+ * - Return the full budget (source of truth)
249
+ *
250
+ * This approach unblocks development while passively collecting drift data.
251
+ *
252
+ * @param budgetId - The budget ID
253
+ * @param options - Sync options (forceSync: 'full' | 'delta' | undefined)
254
+ * @param log - Logger for debug output
255
+ * @returns The LocalBudget
256
+ */
257
+ async getLocalBudgetWithSync(budgetId, options = {}, contextLog = defaultLog) {
258
+ // Create combined logger that writes to both file and context
259
+ const log = createCombinedLogger(contextLog);
260
+ const existingBudget = this.localBudgets.get(budgetId);
261
+ const previousBudgetDetail = this.previousBudgetDetails.get(budgetId);
262
+ const syncDecision = this.determineSyncNeeded(existingBudget, options);
263
+ log.debug('Sync decision', {
264
+ budgetId,
265
+ decision: syncDecision.type,
266
+ hasPreviousBudget: existingBudget !== undefined,
267
+ reason: syncDecision.reason,
268
+ });
269
+ if (syncDecision.type === 'none' && existingBudget !== undefined) {
270
+ log.debug('Local budget is fresh, skipping sync', {
271
+ budgetId,
272
+ lastSyncedAt: existingBudget.lastSyncedAt.toISOString(),
273
+ serverKnowledge: existingBudget.serverKnowledge,
274
+ });
275
+ return existingBudget;
276
+ }
277
+ const syncProvider = this.getSyncProvider();
278
+ const totalStartTime = performance.now();
279
+ // If we have an existing budget, try delta sync first for drift collection
280
+ let mergedBudget = null;
281
+ let deltaBudgetData = null;
282
+ let deltaServerKnowledge = null;
283
+ if (existingBudget !== undefined &&
284
+ previousBudgetDetail !== undefined &&
285
+ isDriftDetectionEnabled() &&
286
+ shouldPerformDriftCheck(budgetId)) {
287
+ // Fetch delta and merge for drift comparison
288
+ const previousServerKnowledge = existingBudget.serverKnowledge;
289
+ log.info('Fetching delta for drift collection...', {
290
+ budgetId,
291
+ previousServerKnowledge,
292
+ });
293
+ try {
294
+ const deltaStartTime = performance.now();
295
+ const deltaResult = await syncProvider.deltaSync(budgetId, previousServerKnowledge);
296
+ deltaBudgetData = deltaResult.budget;
297
+ deltaServerKnowledge = deltaResult.serverKnowledge;
298
+ const deltaDurationMs = Math.round(performance.now() - deltaStartTime);
299
+ // Analyze delta for logging
300
+ const deltaAnalysis = analyzeDeltaResponse(deltaBudgetData);
301
+ const knowledgeChanged = deltaServerKnowledge !== previousServerKnowledge;
302
+ log.debug('Delta response for drift collection', {
303
+ deltaAnalysis,
304
+ deltaDurationMs,
305
+ knowledgeChanged,
306
+ serverKnowledge: {
307
+ new: deltaServerKnowledge,
308
+ previous: previousServerKnowledge,
309
+ },
310
+ });
311
+ // Merge delta into existing budget
312
+ const mergeStartTime = performance.now();
313
+ const mergeResult = mergeDelta(existingBudget, deltaBudgetData, deltaServerKnowledge);
314
+ mergedBudget = mergeResult.localBudget;
315
+ const mergeDurationMs = Math.round(performance.now() - mergeStartTime);
316
+ log.debug('Delta merged for drift comparison', {
317
+ changesReceived: mergeResult.changesReceived,
318
+ mergeDurationMs,
319
+ });
320
+ }
321
+ catch (error) {
322
+ log.warn('Delta fetch failed for drift collection, continuing', {
323
+ budgetId,
324
+ error: error instanceof Error ? error.message : String(error),
325
+ });
326
+ }
327
+ }
328
+ // Always fetch full budget (source of truth)
329
+ log.info('Performing full sync...', {
330
+ budgetId,
331
+ reason: syncDecision.reason,
332
+ });
333
+ const apiStartTime = performance.now();
334
+ const { budget: fullBudgetData, serverKnowledge: fullServerKnowledge } = await syncProvider.fullSync(budgetId);
335
+ const apiDurationMs = Math.round(performance.now() - apiStartTime);
336
+ const buildStartTime = performance.now();
337
+ const localBudget = buildLocalBudget(budgetId, fullBudgetData, fullServerKnowledge);
338
+ const buildDurationMs = Math.round(performance.now() - buildStartTime);
339
+ // Persist to sync history
340
+ const persistStartTime = performance.now();
341
+ await persistSyncResponse(budgetId, 'full', fullBudgetData, fullServerKnowledge, null, log);
342
+ const persistDurationMs = Math.round(performance.now() - persistStartTime);
343
+ const totalDurationMs = Math.round(performance.now() - totalStartTime);
344
+ log.info('Full sync completed', {
345
+ apiDurationMs,
346
+ budgetId,
347
+ buildDurationMs,
348
+ counts: {
349
+ accounts: localBudget.accounts.length,
350
+ categories: localBudget.categories.length,
351
+ payees: localBudget.payees.length,
352
+ transactions: localBudget.transactions.length,
353
+ },
354
+ persistDurationMs,
355
+ serverKnowledge: fullServerKnowledge,
356
+ totalDurationMs,
357
+ });
358
+ // Run drift detection if we have a merged budget to compare
359
+ if (mergedBudget !== null &&
360
+ deltaBudgetData !== null &&
361
+ deltaServerKnowledge !== null &&
362
+ previousBudgetDetail !== undefined) {
363
+ log.info('Running drift detection...', { budgetId });
364
+ const compareStartTime = performance.now();
365
+ const driftResult = checkForDrift(mergedBudget, localBudget);
366
+ const compareDurationMs = Math.round(performance.now() - compareStartTime);
367
+ logDriftCheckResult(driftResult, budgetId, log);
368
+ recordDriftCheck(budgetId);
369
+ log.debug('Drift check timing', { compareDurationMs });
370
+ // Save snapshot if drift detected and we're sampling
371
+ if (driftResult.hasDrift && shouldSampleDrift()) {
372
+ try {
373
+ await saveDriftSnapshot({
374
+ budgetId,
375
+ deltaResponse: deltaBudgetData,
376
+ driftResult,
377
+ fullResponse: fullBudgetData,
378
+ mergedBudget,
379
+ previousFullResponse: previousBudgetDetail,
380
+ serverKnowledge: {
381
+ afterDelta: deltaServerKnowledge,
382
+ afterFull: fullServerKnowledge,
383
+ previous: existingBudget?.serverKnowledge ?? 0,
384
+ },
385
+ }, log);
386
+ }
387
+ catch (error) {
388
+ log.warn('Failed to save drift snapshot, continuing', {
389
+ budgetId,
390
+ error: error instanceof Error ? error.message : String(error),
391
+ });
392
+ }
393
+ }
394
+ }
395
+ // Store the full budget and response for next time
396
+ this.localBudgets.set(budgetId, localBudget);
397
+ this.previousBudgetDetails.set(budgetId, fullBudgetData);
398
+ return localBudget;
399
+ }
400
+ /**
401
+ * Mark a budget as needing sync (called after write operations).
402
+ * The next read operation will trigger a delta sync.
403
+ */
404
+ markNeedsSync(budgetId) {
405
+ const localBudget = this.localBudgets.get(budgetId);
406
+ if (localBudget !== undefined) {
407
+ localBudget.needsSync = true;
408
+ }
409
+ }
410
+ /**
411
+ * Get all budgets
412
+ */
413
+ async getBudgets() {
414
+ if (this.budgets === null) {
415
+ const response = await this.getApi().budgets.getBudgets();
416
+ this.budgets = response.data.budgets;
417
+ }
418
+ return this.budgets.map((b) => ({
419
+ currency_format: b.currency_format !== undefined && b.currency_format !== null
420
+ ? {
421
+ currency_symbol: b.currency_format.currency_symbol,
422
+ decimal_digits: b.currency_format.decimal_digits,
423
+ decimal_separator: b.currency_format.decimal_separator,
424
+ example_format: b.currency_format.example_format,
425
+ iso_code: b.currency_format.iso_code,
426
+ symbol_first: b.currency_format.symbol_first,
427
+ }
428
+ : null,
429
+ first_month: b.first_month ?? null,
430
+ id: b.id,
431
+ last_modified_on: b.last_modified_on ?? null,
432
+ last_month: b.last_month ?? null,
433
+ name: b.name,
434
+ }));
435
+ }
436
+ /**
437
+ * Resolve a budget selector to a budget ID
438
+ *
439
+ * IMPORTANT: If YNAB_BUDGET_ID is set, it acts as a HARD CONSTRAINT.
440
+ * Only that budget can be accessed - any attempt to use a different
441
+ * budget will throw an error. This is a safety mechanism for testing.
442
+ */
443
+ async resolveBudgetId(selector) {
444
+ const budgets = await this.getBudgets();
445
+ const hasName = selector?.name !== undefined && selector.name !== '';
446
+ const hasId = selector?.id !== undefined && selector.id !== '';
447
+ // Check for YNAB_BUDGET_ID environment variable - this is a HARD CONSTRAINT
448
+ const envBudgetId = process.env['YNAB_BUDGET_ID'];
449
+ const hasEnvConstraint = envBudgetId !== undefined && envBudgetId !== '';
450
+ if (hasEnvConstraint) {
451
+ // Verify the constrained budget exists
452
+ const constrainedBudget = budgets.find((b) => b.id === envBudgetId);
453
+ if (constrainedBudget === undefined) {
454
+ throw new Error(`YNAB_BUDGET_ID is set to '${envBudgetId}' but no budget with that ID exists. ` +
455
+ `Available budgets: ${budgets.map((b) => `${b.name} (${b.id})`).join(', ')}.`);
456
+ }
457
+ // If no selector provided, use the constrained budget
458
+ if (selector === undefined || (!hasName && !hasId)) {
459
+ this.lastUsedBudgetId = constrainedBudget.id;
460
+ return constrainedBudget.id;
461
+ }
462
+ // Validate selector format
463
+ if (hasName && hasId) {
464
+ throw new Error("Budget selector must specify exactly one of: 'name' or 'id'.");
465
+ }
466
+ // If selector specifies an ID, it MUST match the constrained ID
467
+ if (hasId) {
468
+ if (selector.id !== envBudgetId) {
469
+ throw new Error(`Budget access denied. YNAB_BUDGET_ID restricts access to budget '${constrainedBudget.name}' (${envBudgetId}). ` +
470
+ `Attempted to access budget with ID: '${selector.id}'.`);
471
+ }
472
+ this.lastUsedBudgetId = constrainedBudget.id;
473
+ return constrainedBudget.id;
474
+ }
475
+ // If selector specifies a name, resolve it and verify it matches
476
+ const nameLower = (selector.name ?? '').toLowerCase();
477
+ const namedBudget = budgets.find((b) => b.name.toLowerCase() === nameLower);
478
+ if (namedBudget === undefined) {
479
+ throw new Error(`No budget found with name: '${selector.name}'. ` +
480
+ `Note: YNAB_BUDGET_ID restricts access to '${constrainedBudget.name}'.`);
481
+ }
482
+ if (namedBudget.id !== envBudgetId) {
483
+ throw new Error(`Budget access denied. YNAB_BUDGET_ID restricts access to budget '${constrainedBudget.name}' (${envBudgetId}). ` +
484
+ `Attempted to access budget '${namedBudget.name}' (${namedBudget.id}).`);
485
+ }
486
+ this.lastUsedBudgetId = constrainedBudget.id;
487
+ return constrainedBudget.id;
488
+ }
489
+ // No env constraint - use normal resolution logic
490
+ // If no selector provided, use last-used or single budget
491
+ if (selector === undefined || (!hasName && !hasId)) {
492
+ if (this.lastUsedBudgetId !== null) {
493
+ return this.lastUsedBudgetId;
494
+ }
495
+ const firstBudget = budgets[0];
496
+ if (budgets.length === 1 && firstBudget !== undefined) {
497
+ this.lastUsedBudgetId = firstBudget.id;
498
+ return firstBudget.id;
499
+ }
500
+ const budgetNames = budgets.map((b) => b.name).join(', ');
501
+ throw new Error(`Multiple budgets found. Please specify which budget using {"name": "..."} or {"id": "..."}. Available: ${budgetNames}.`);
502
+ }
503
+ // Validate selector has exactly one of name or id
504
+ if (hasName && hasId) {
505
+ throw new Error("Budget selector must specify exactly one of: 'name' or 'id'.");
506
+ }
507
+ // Find by ID
508
+ if (hasId) {
509
+ const budget = budgets.find((b) => b.id === selector.id);
510
+ if (budget === undefined) {
511
+ const budgetNames = budgets.map((b) => b.name).join(', ');
512
+ throw new Error(`No budget found with ID: '${selector.id}'. Available budgets: ${budgetNames}.`);
513
+ }
514
+ this.lastUsedBudgetId = budget.id;
515
+ return budget.id;
516
+ }
517
+ // Find by name (case-insensitive)
518
+ // At this point we know hasName is true since hasId was false
519
+ const nameLower = (selector.name ?? '').toLowerCase();
520
+ const budget = budgets.find((b) => b.name.toLowerCase() === nameLower);
521
+ if (budget === undefined) {
522
+ const budgetNames = budgets.map((b) => b.name).join(', ');
523
+ throw new Error(`No budget found with name: '${selector.name}'. Available budgets: ${budgetNames}.`);
524
+ }
525
+ this.lastUsedBudgetId = budget.id;
526
+ return budget.id;
527
+ }
528
+ /**
529
+ * Get the local budget for internal use.
530
+ * This is a convenience wrapper around getLocalBudgetWithSync() for methods
531
+ * that don't have access to a logger context.
532
+ */
533
+ async getLocalBudget(budgetId) {
534
+ return await this.getLocalBudgetWithSync(budgetId, {}, defaultLog);
535
+ }
536
+ /**
537
+ * Build CategoryGroupWithCategories array from LocalBudget data.
538
+ * The full budget endpoint returns categories and groups separately,
539
+ * so we need to join them for compatibility with some existing methods.
540
+ */
541
+ buildCategoryGroupsWithCategories(localBudget) {
542
+ // Group categories by their category_group_id
543
+ const categoriesByGroupId = new Map();
544
+ for (const category of localBudget.categories) {
545
+ const existing = categoriesByGroupId.get(category.category_group_id);
546
+ if (existing !== undefined) {
547
+ existing.push(category);
548
+ }
549
+ else {
550
+ categoriesByGroupId.set(category.category_group_id, [category]);
551
+ }
552
+ }
553
+ // Build CategoryGroupWithCategories array
554
+ return localBudget.categoryGroups.map((group) => ({
555
+ ...group,
556
+ categories: categoriesByGroupId.get(group.id) ?? [],
557
+ }));
558
+ }
559
+ /**
560
+ * Convert milliunits to currency amount
561
+ */
562
+ toCurrency(milliunits, currencyFormat) {
563
+ const decimalDigits = currencyFormat?.decimal_digits ?? 2;
564
+ return utils.convertMilliUnitsToCurrencyAmount(milliunits, decimalDigits);
565
+ }
566
+ /**
567
+ * Enrich a transaction with resolved names.
568
+ * Used for TransactionDetail (from individual transaction endpoints),
569
+ * which already has payee_name, category_name, account_name, and
570
+ * inline subtransactions[].
571
+ */
572
+ enrichTransaction(tx, localBudget) {
573
+ const { categoryGroupName, categoryName } = resolveCategoryInfo(tx.category_id, tx.category_name, localBudget);
574
+ // Enrich subtransactions (TransactionDetail has inline subtransactions)
575
+ const enrichedSubtransactions = tx.subtransactions.map((sub) => {
576
+ const subCategory = resolveCategoryInfo(sub.category_id, sub.category_name, localBudget);
577
+ return {
578
+ amount: sub.amount,
579
+ amount_currency: this.toCurrency(sub.amount, localBudget.currencyFormat),
580
+ category_group_name: subCategory.categoryGroupName,
581
+ category_id: sub.category_id ?? null,
582
+ category_name: subCategory.categoryName,
583
+ id: sub.id,
584
+ memo: sub.memo ?? null,
585
+ payee_id: sub.payee_id ?? null,
586
+ payee_name: resolvePayeeName(sub.payee_id, sub.payee_name, localBudget),
587
+ transaction_id: sub.transaction_id,
588
+ transfer_account_id: sub.transfer_account_id ?? null,
589
+ };
590
+ });
591
+ return {
592
+ account_id: tx.account_id,
593
+ account_name: tx.account_name,
594
+ amount: tx.amount,
595
+ amount_currency: this.toCurrency(tx.amount, localBudget.currencyFormat),
596
+ approved: tx.approved,
597
+ category_group_name: categoryGroupName,
598
+ category_id: tx.category_id ?? null,
599
+ category_name: categoryName,
600
+ cleared: tx.cleared,
601
+ date: tx.date,
602
+ flag_color: tx.flag_color ?? null,
603
+ id: tx.id,
604
+ import_id: tx.import_id ?? null,
605
+ import_payee_name: tx.import_payee_name ?? null,
606
+ import_payee_name_original: tx.import_payee_name_original ?? null,
607
+ memo: tx.memo ?? null,
608
+ payee_id: tx.payee_id ?? null,
609
+ payee_name: tx.payee_name ?? null,
610
+ subtransactions: enrichedSubtransactions,
611
+ transfer_account_id: tx.transfer_account_id ?? null,
612
+ };
613
+ }
614
+ /**
615
+ * Enrich a TransactionSummary (from full budget endpoint) with resolved names.
616
+ *
617
+ * The full budget endpoint returns TransactionSummary[] which only has IDs,
618
+ * not resolved names. This method looks up names from the LocalBudget's
619
+ * lookup maps and joins subtransactions from the flat subtransactions array.
620
+ *
621
+ * @param tx - The TransactionSummary from LocalBudget.transactions
622
+ * @param localBudget - The LocalBudget with lookup maps and subtransactions
623
+ * @returns An EnrichedTransaction with resolved names and subtransactions
624
+ */
625
+ enrichTransactionSummary(tx, localBudget) {
626
+ // Look up account name
627
+ const account = localBudget.accountById.get(tx.account_id);
628
+ const accountName = account?.name ?? 'Unknown Account';
629
+ const payeeName = resolvePayeeName(tx.payee_id, null, localBudget);
630
+ const { categoryGroupName, categoryName } = resolveCategoryInfo(tx.category_id, null, localBudget);
631
+ // Get subtransactions for this transaction (O(1) lookup)
632
+ const subtransactions = (localBudget.subtransactionsByTransactionId.get(tx.id) ?? []).filter((sub) => sub.deleted !== true);
633
+ // Enrich subtransactions with resolved names
634
+ const enrichedSubtransactions = subtransactions.map((sub) => {
635
+ const subCategory = resolveCategoryInfo(sub.category_id, sub.category_name, localBudget);
636
+ return {
637
+ amount: sub.amount,
638
+ amount_currency: this.toCurrency(sub.amount, localBudget.currencyFormat),
639
+ category_group_name: subCategory.categoryGroupName,
640
+ category_id: sub.category_id ?? null,
641
+ category_name: subCategory.categoryName,
642
+ id: sub.id,
643
+ memo: sub.memo ?? null,
644
+ payee_id: sub.payee_id ?? null,
645
+ payee_name: resolvePayeeName(sub.payee_id, sub.payee_name, localBudget),
646
+ transaction_id: sub.transaction_id,
647
+ transfer_account_id: sub.transfer_account_id ?? null,
648
+ };
649
+ });
650
+ return {
651
+ account_id: tx.account_id,
652
+ account_name: accountName,
653
+ amount: tx.amount,
654
+ amount_currency: this.toCurrency(tx.amount, localBudget.currencyFormat),
655
+ approved: tx.approved,
656
+ category_group_name: categoryGroupName,
657
+ category_id: tx.category_id ?? null,
658
+ category_name: categoryName,
659
+ cleared: tx.cleared,
660
+ date: tx.date,
661
+ flag_color: tx.flag_color ?? null,
662
+ id: tx.id,
663
+ import_id: tx.import_id ?? null,
664
+ import_payee_name: tx.import_payee_name ?? null,
665
+ import_payee_name_original: tx.import_payee_name_original ?? null,
666
+ memo: tx.memo ?? null,
667
+ payee_id: tx.payee_id ?? null,
668
+ payee_name: payeeName,
669
+ subtransactions: enrichedSubtransactions,
670
+ transfer_account_id: tx.transfer_account_id ?? null,
671
+ };
672
+ }
673
+ /**
674
+ * Get transactions with optional filters.
675
+ *
676
+ * Reads from LocalBudget.transactions and applies filters locally.
677
+ * No API call is made - data comes from the synced local budget.
678
+ */
679
+ async getTransactions(budgetId, options = {}) {
680
+ const localBudget = await this.getLocalBudget(budgetId);
681
+ // Start with all non-deleted transactions
682
+ let transactions = localBudget.transactions.filter((tx) => tx.deleted !== true);
683
+ // Filter by account if specified
684
+ if (options.accountId !== undefined && options.accountId !== '') {
685
+ transactions = transactions.filter((tx) => tx.account_id === options.accountId);
686
+ }
687
+ // Filter by date if specified (since_date is inclusive)
688
+ if (options.sinceDate !== undefined && options.sinceDate !== '') {
689
+ const sinceDate = options.sinceDate;
690
+ transactions = transactions.filter((tx) => tx.date >= sinceDate);
691
+ }
692
+ // Filter by type
693
+ if (options.type === 'uncategorized') {
694
+ // Uncategorized = no category assigned
695
+ transactions = transactions.filter((tx) => tx.category_id === undefined || tx.category_id === null);
696
+ }
697
+ else if (options.type === 'unapproved') {
698
+ // Unapproved = not yet approved
699
+ transactions = transactions.filter((tx) => tx.approved !== true);
700
+ }
701
+ // Enrich and return
702
+ return transactions.map((tx) => this.enrichTransactionSummary(tx, localBudget));
703
+ }
704
+ /**
705
+ * Resolve an account selector to an account ID
706
+ */
707
+ async resolveAccountId(budgetId, selector) {
708
+ const cache = await this.getLocalBudget(budgetId);
709
+ // Validate selector has exactly one of name or id
710
+ const selectorHasName = selector.name !== undefined && selector.name !== '';
711
+ const selectorHasId = selector.id !== undefined && selector.id !== '';
712
+ if (selectorHasName && selectorHasId) {
713
+ throw new Error("Account selector must specify exactly one of: 'name' or 'id'.");
714
+ }
715
+ if (!selectorHasName && !selectorHasId) {
716
+ throw new Error("Account selector must specify 'name' or 'id'.");
717
+ }
718
+ // Find by ID
719
+ if (selectorHasId) {
720
+ const account = cache.accountById.get(selector.id ?? '');
721
+ if (account === undefined) {
722
+ const accountNames = cache.accounts
723
+ .filter((a) => !a.closed && !a.deleted)
724
+ .map((a) => a.name)
725
+ .join(', ');
726
+ throw new Error(`No account found with ID: '${selector.id}'. Available accounts: ${accountNames}.`);
727
+ }
728
+ return account.id;
729
+ }
730
+ // Find by name (case-insensitive)
731
+ const nameLower = (selector.name ?? '').toLowerCase();
732
+ const account = cache.accountByName.get(nameLower);
733
+ if (account === undefined) {
734
+ const accountNames = cache.accounts
735
+ .filter((a) => !a.closed && !a.deleted)
736
+ .map((a) => a.name)
737
+ .join(', ');
738
+ throw new Error(`No account found with name: '${selector.name}'. Available accounts: ${accountNames}.`);
739
+ }
740
+ return account.id;
741
+ }
742
+ /**
743
+ * Get all accounts for a budget
744
+ */
745
+ async getAccounts(budgetId, includeClosed = false) {
746
+ const cache = await this.getLocalBudget(budgetId);
747
+ return cache.accounts
748
+ .filter((a) => !a.deleted && (includeClosed || !a.closed))
749
+ .map((a) => ({
750
+ balance: a.balance,
751
+ balance_currency: this.toCurrency(a.balance, cache.currencyFormat),
752
+ cleared_balance: a.cleared_balance,
753
+ cleared_balance_currency: this.toCurrency(a.cleared_balance, cache.currencyFormat),
754
+ closed: a.closed,
755
+ direct_import_in_error: a.direct_import_in_error ?? false,
756
+ direct_import_linked: a.direct_import_linked ?? false,
757
+ id: a.id,
758
+ name: a.name,
759
+ on_budget: a.on_budget,
760
+ type: a.type,
761
+ uncleared_balance: a.uncleared_balance,
762
+ uncleared_balance_currency: this.toCurrency(a.uncleared_balance, cache.currencyFormat),
763
+ }));
764
+ }
765
+ /**
766
+ * Get all categories for a budget
767
+ */
768
+ async getCategories(budgetId, includeHidden = false) {
769
+ const cache = await this.getLocalBudget(budgetId);
770
+ const flat = cache.categories
771
+ .filter((c) => !c.deleted && (includeHidden || !c.hidden))
772
+ .map((c) => ({
773
+ activity: c.activity,
774
+ balance: c.balance,
775
+ budgeted: c.budgeted,
776
+ category_group_id: c.category_group_id,
777
+ category_group_name: cache.categoryGroupNameById.get(c.category_group_id) ?? '',
778
+ deleted: c.deleted,
779
+ hidden: c.hidden,
780
+ id: c.id,
781
+ name: c.name,
782
+ }));
783
+ return { flat, groups: this.buildCategoryGroupsWithCategories(cache) };
784
+ }
785
+ /**
786
+ * Get all payees for a budget
787
+ */
788
+ async getPayees(budgetId) {
789
+ const cache = await this.getLocalBudget(budgetId);
790
+ return cache.payees
791
+ .filter((p) => !p.deleted)
792
+ .map((p) => ({
793
+ id: p.id,
794
+ name: p.name,
795
+ transfer_account_id: p.transfer_account_id ?? null,
796
+ }));
797
+ }
798
+ /**
799
+ * Update multiple transactions using the bulk PATCH endpoint
800
+ */
801
+ async updateTransactions(budgetId, updates) {
802
+ assertWriteAllowed('update_transactions');
803
+ const api = this.getApi();
804
+ // Map cleared string to YNAB enum
805
+ const mapCleared = (cleared) => {
806
+ if (cleared === undefined)
807
+ return undefined;
808
+ switch (cleared) {
809
+ case 'cleared':
810
+ return TransactionClearedStatus.Cleared;
811
+ case 'reconciled':
812
+ return TransactionClearedStatus.Reconciled;
813
+ case 'uncleared':
814
+ return TransactionClearedStatus.Uncleared;
815
+ default:
816
+ return undefined;
817
+ }
818
+ };
819
+ // Build the patch request with all supported fields
820
+ // TODO: Consider adding a merge mode that fetches existing subtransactions
821
+ // and merges with the provided ones, for a more intuitive UX. Currently,
822
+ // providing subtransactions will OVERWRITE all existing ones (matching YNAB API behavior).
823
+ const patchTransactions = updates.map((u) => ({
824
+ account_id: u.account_id,
825
+ amount: u.amount,
826
+ approved: u.approved,
827
+ category_id: u.category_id,
828
+ cleared: mapCleared(u.cleared),
829
+ date: u.date,
830
+ flag_color: u.flag_color,
831
+ id: u.id,
832
+ memo: u.memo,
833
+ payee_id: u.payee_id,
834
+ payee_name: u.payee_name,
835
+ subtransactions: u.subtransactions?.map((sub) => ({
836
+ amount: sub.amount,
837
+ category_id: sub.category_id,
838
+ memo: sub.memo,
839
+ payee_id: sub.payee_id,
840
+ payee_name: sub.payee_name,
841
+ })),
842
+ }));
843
+ const response = await api.transactions.updateTransactions(budgetId, {
844
+ transactions: patchTransactions,
845
+ });
846
+ // The API returns SaveTransactionsResponse which has transaction_ids for new ones
847
+ // and transactions array for updated ones
848
+ const updatedTxs = response.data.transactions ?? [];
849
+ // Always invalidate cache after write operations to ensure consistency
850
+ // (payees may be created, and we want fresh data for enrichment)
851
+ this.markNeedsSync(budgetId);
852
+ const freshCache = await this.getLocalBudget(budgetId);
853
+ const enriched = updatedTxs.map((tx) => this.enrichTransaction(tx, freshCache));
854
+ return {
855
+ updated: enriched,
856
+ };
857
+ }
858
+ /**
859
+ * Get currency format for a budget
860
+ */
861
+ async getCurrencyFormat(budgetId) {
862
+ const cache = await this.getLocalBudget(budgetId);
863
+ return cache.currencyFormat;
864
+ }
865
+ /**
866
+ * Get budget info (name, id) for journaling
867
+ */
868
+ async getBudgetInfo(budgetId) {
869
+ const budgets = await this.getBudgets();
870
+ const budget = budgets.find((b) => b.id === budgetId);
871
+ if (budget === undefined) {
872
+ return { id: budgetId, name: 'Unknown Budget' };
873
+ }
874
+ return { id: budget.id, name: budget.name };
875
+ }
876
+ /**
877
+ * Get raw budget detail for backup purposes
878
+ * Returns the complete budget data as returned by YNAB API
879
+ * (effectively a full budget export per YNAB docs)
880
+ */
881
+ async getBudgetByIdRaw(budgetId) {
882
+ const api = this.getApi();
883
+ const response = await api.budgets.getBudgetById(budgetId);
884
+ return {
885
+ budget: response.data.budget,
886
+ server_knowledge: response.data.server_knowledge,
887
+ };
888
+ }
889
+ /**
890
+ * Resolve a category selector to a category ID
891
+ */
892
+ async resolveCategoryId(budgetId, selector) {
893
+ const cache = await this.getLocalBudget(budgetId);
894
+ const selectorHasName = selector.name !== undefined && selector.name !== '';
895
+ const selectorHasId = selector.id !== undefined && selector.id !== '';
896
+ if (selectorHasName && selectorHasId) {
897
+ throw new Error("Category selector must specify exactly one of: 'name' or 'id'.");
898
+ }
899
+ if (!selectorHasName && !selectorHasId) {
900
+ throw new Error("Category selector must specify 'name' or 'id'.");
901
+ }
902
+ // Find by ID
903
+ if (selectorHasId) {
904
+ const category = cache.categoryById.get(selector.id ?? '');
905
+ if (category === undefined) {
906
+ const categoryNames = cache.categories
907
+ .filter((c) => !c.deleted && !c.hidden)
908
+ .slice(0, 20)
909
+ .map((c) => c.name)
910
+ .join(', ');
911
+ throw new Error(`No category found with ID: '${selector.id}'. Some available categories: ${categoryNames}...`);
912
+ }
913
+ return category.id;
914
+ }
915
+ // Find by name (case-insensitive)
916
+ const nameLower = (selector.name ?? '').toLowerCase();
917
+ const category = cache.categories.find((c) => !c.deleted && c.name.toLowerCase() === nameLower);
918
+ if (category === undefined) {
919
+ const categoryNames = cache.categories
920
+ .filter((c) => !c.deleted && !c.hidden)
921
+ .slice(0, 20)
922
+ .map((c) => c.name)
923
+ .join(', ');
924
+ throw new Error(`No category found with name: '${selector.name}'. Some available categories: ${categoryNames}...`);
925
+ }
926
+ return category.id;
927
+ }
928
+ /**
929
+ * Resolve a payee selector to a payee ID
930
+ */
931
+ async resolvePayeeId(budgetId, selector) {
932
+ const cache = await this.getLocalBudget(budgetId);
933
+ const selectorHasName = selector.name !== undefined && selector.name !== '';
934
+ const selectorHasId = selector.id !== undefined && selector.id !== '';
935
+ if (selectorHasName && selectorHasId) {
936
+ throw new Error("Payee selector must specify exactly one of: 'name' or 'id'.");
937
+ }
938
+ if (!selectorHasName && !selectorHasId) {
939
+ return null; // No payee specified
940
+ }
941
+ // Find by ID
942
+ if (selectorHasId) {
943
+ const payee = cache.payeeById.get(selector.id ?? '');
944
+ if (payee === undefined) {
945
+ throw new Error(`No payee found with ID: '${selector.id}'.`);
946
+ }
947
+ return payee.id;
948
+ }
949
+ // Find by name (case-insensitive) - return null if not found (will create new)
950
+ const nameLower = (selector.name ?? '').toLowerCase();
951
+ const payee = cache.payees.find((p) => !p.deleted && p.name.toLowerCase() === nameLower);
952
+ // Return null if not found - YNAB will create the payee
953
+ return payee?.id ?? null;
954
+ }
955
+ /**
956
+ * Get a single transaction by ID.
957
+ *
958
+ * Reads from LocalBudget.transactions - no API call is made.
959
+ */
960
+ async getTransaction(budgetId, transactionId) {
961
+ const localBudget = await this.getLocalBudget(budgetId);
962
+ // Find the transaction in local budget (check all, including deleted)
963
+ const transaction = localBudget.transactions.find((tx) => tx.id === transactionId);
964
+ if (transaction === undefined) {
965
+ throw new Error(`Transaction not found with ID: '${transactionId}'.`);
966
+ }
967
+ if (transaction.deleted === true) {
968
+ throw new Error(`Transaction '${transactionId}' has been deleted in YNAB.`);
969
+ }
970
+ return this.enrichTransactionSummary(transaction, localBudget);
971
+ }
972
+ /**
973
+ * Get scheduled transactions.
974
+ *
975
+ * Reads from LocalBudget.scheduledTransactions - no API call is made.
976
+ */
977
+ async getScheduledTransactions(budgetId) {
978
+ const localBudget = await this.getLocalBudget(budgetId);
979
+ return localBudget.scheduledTransactions
980
+ .filter((txn) => txn.deleted !== true)
981
+ .map((txn) => {
982
+ const account = localBudget.accountById.get(txn.account_id);
983
+ const accountName = account?.name ?? 'Unknown Account';
984
+ const payeeName = resolvePayeeName(txn.payee_id, null, localBudget);
985
+ const { categoryName } = resolveCategoryInfo(txn.category_id, null, localBudget);
986
+ // Get subtransactions for this scheduled transaction (O(1) lookup)
987
+ const subtransactions = (localBudget.scheduledSubtransactionsByScheduledTransactionId.get(txn.id) ?? []).filter((sub) => sub.deleted !== true);
988
+ return {
989
+ account_id: txn.account_id,
990
+ account_name: accountName,
991
+ amount: txn.amount,
992
+ amount_currency: this.toCurrency(txn.amount, localBudget.currencyFormat),
993
+ category_id: txn.category_id ?? null,
994
+ category_name: categoryName,
995
+ date_first: txn.date_first,
996
+ date_next: txn.date_next,
997
+ flag_color: txn.flag_color ?? null,
998
+ frequency: txn.frequency,
999
+ id: txn.id,
1000
+ memo: txn.memo ?? null,
1001
+ payee_id: txn.payee_id ?? null,
1002
+ payee_name: payeeName,
1003
+ subtransactions: subtransactions.map((sub) => ({
1004
+ amount: sub.amount,
1005
+ amount_currency: this.toCurrency(sub.amount, localBudget.currencyFormat),
1006
+ category_id: sub.category_id ?? null,
1007
+ category_name: resolveCategoryInfo(sub.category_id, sub.category_name, localBudget).categoryName,
1008
+ id: sub.id,
1009
+ memo: sub.memo ?? null,
1010
+ payee_id: sub.payee_id ?? null,
1011
+ payee_name: resolvePayeeName(sub.payee_id, sub.payee_name, localBudget),
1012
+ scheduled_transaction_id: sub.scheduled_transaction_id,
1013
+ transfer_account_id: sub.transfer_account_id ?? null,
1014
+ })),
1015
+ transfer_account_id: txn.transfer_account_id ?? null,
1016
+ };
1017
+ });
1018
+ }
1019
+ /**
1020
+ * Get budget months list.
1021
+ *
1022
+ * Reads from LocalBudget.months - no API call is made.
1023
+ */
1024
+ async getBudgetMonths(budgetId) {
1025
+ const localBudget = await this.getLocalBudget(budgetId);
1026
+ return localBudget.months
1027
+ .filter((month) => month.deleted !== true)
1028
+ .map((month) => ({
1029
+ activity: month.activity,
1030
+ activity_currency: this.toCurrency(month.activity, localBudget.currencyFormat),
1031
+ age_of_money: month.age_of_money ?? null,
1032
+ budgeted: month.budgeted,
1033
+ budgeted_currency: this.toCurrency(month.budgeted, localBudget.currencyFormat),
1034
+ income: month.income,
1035
+ income_currency: this.toCurrency(month.income, localBudget.currencyFormat),
1036
+ month: month.month,
1037
+ note: month.note ?? null,
1038
+ to_be_budgeted: month.to_be_budgeted,
1039
+ to_be_budgeted_currency: this.toCurrency(month.to_be_budgeted, localBudget.currencyFormat),
1040
+ }));
1041
+ }
1042
+ /**
1043
+ * Get budget month detail with categories.
1044
+ *
1045
+ * Reads from LocalBudget.months - no API call is made.
1046
+ */
1047
+ async getBudgetMonth(budgetId, month) {
1048
+ const localBudget = await this.getLocalBudget(budgetId);
1049
+ // Find the month in local budget (check all, including deleted)
1050
+ const monthData = localBudget.months.find((m) => m.month === month);
1051
+ if (monthData === undefined) {
1052
+ throw new Error(`Budget month not found: '${month}'.`);
1053
+ }
1054
+ if (monthData.deleted === true) {
1055
+ throw new Error(`Budget month '${month}' has been deleted in YNAB.`);
1056
+ }
1057
+ const categories = monthData.categories
1058
+ .filter((c) => !c.deleted)
1059
+ .map((c) => ({
1060
+ activity: c.activity,
1061
+ activity_currency: this.toCurrency(c.activity, localBudget.currencyFormat),
1062
+ balance: c.balance,
1063
+ balance_currency: this.toCurrency(c.balance, localBudget.currencyFormat),
1064
+ budgeted: c.budgeted,
1065
+ budgeted_currency: this.toCurrency(c.budgeted, localBudget.currencyFormat),
1066
+ category_group_id: c.category_group_id,
1067
+ category_group_name: localBudget.categoryGroupNameById.get(c.category_group_id) ?? '',
1068
+ goal_percentage_complete: c.goal_percentage_complete ?? null,
1069
+ goal_target: c.goal_target ?? null,
1070
+ goal_type: c.goal_type ?? null,
1071
+ hidden: c.hidden,
1072
+ id: c.id,
1073
+ name: c.name,
1074
+ }));
1075
+ return {
1076
+ activity: monthData.activity,
1077
+ activity_currency: this.toCurrency(monthData.activity, localBudget.currencyFormat),
1078
+ age_of_money: monthData.age_of_money ?? null,
1079
+ budgeted: monthData.budgeted,
1080
+ budgeted_currency: this.toCurrency(monthData.budgeted, localBudget.currencyFormat),
1081
+ categories,
1082
+ income: monthData.income,
1083
+ income_currency: this.toCurrency(monthData.income, localBudget.currencyFormat),
1084
+ month: monthData.month,
1085
+ note: monthData.note ?? null,
1086
+ to_be_budgeted: monthData.to_be_budgeted,
1087
+ to_be_budgeted_currency: this.toCurrency(monthData.to_be_budgeted, localBudget.currencyFormat),
1088
+ };
1089
+ }
1090
+ /**
1091
+ * Create one or more transactions
1092
+ */
1093
+ async createTransactions(budgetId, transactions) {
1094
+ assertWriteAllowed('create_transactions');
1095
+ const api = this.getApi();
1096
+ const response = await api.transactions.createTransaction(budgetId, {
1097
+ transactions: transactions.map((transaction) => ({
1098
+ account_id: transaction.account_id,
1099
+ amount: transaction.amount,
1100
+ approved: transaction.approved ?? false,
1101
+ category_id: transaction.category_id,
1102
+ cleared: transaction.cleared === 'reconciled'
1103
+ ? TransactionClearedStatus.Reconciled
1104
+ : transaction.cleared === 'cleared'
1105
+ ? TransactionClearedStatus.Cleared
1106
+ : TransactionClearedStatus.Uncleared,
1107
+ date: transaction.date,
1108
+ flag_color: transaction.flag_color,
1109
+ memo: transaction.memo,
1110
+ payee_id: transaction.payee_id,
1111
+ payee_name: transaction.payee_name,
1112
+ subtransactions: transaction.subtransactions?.map((sub) => ({
1113
+ amount: sub.amount,
1114
+ category_id: sub.category_id,
1115
+ memo: sub.memo,
1116
+ payee_id: sub.payee_id,
1117
+ payee_name: sub.payee_name,
1118
+ })),
1119
+ })),
1120
+ });
1121
+ // Invalidate cache since payees may have been created
1122
+ this.markNeedsSync(budgetId);
1123
+ const createdTransactions = response.data.transactions ?? [];
1124
+ const duplicateImportIds = response.data.duplicate_import_ids ?? [];
1125
+ const newCache = await this.getLocalBudget(budgetId);
1126
+ const enriched = createdTransactions.map((t) => this.enrichTransaction(t, newCache));
1127
+ return {
1128
+ created: enriched,
1129
+ duplicates: duplicateImportIds,
1130
+ };
1131
+ }
1132
+ /**
1133
+ * Delete a transaction
1134
+ */
1135
+ async deleteTransaction(budgetId, transactionId) {
1136
+ assertWriteAllowed('delete_transaction');
1137
+ const cache = await this.getLocalBudget(budgetId);
1138
+ const api = this.getApi();
1139
+ const response = await api.transactions.deleteTransaction(budgetId, transactionId);
1140
+ return {
1141
+ deleted: this.enrichTransaction(response.data.transaction, cache),
1142
+ };
1143
+ }
1144
+ /**
1145
+ * Import transactions from linked accounts
1146
+ */
1147
+ async importTransactions(budgetId) {
1148
+ assertWriteAllowed('import_transactions');
1149
+ const api = this.getApi();
1150
+ const response = await api.transactions.importTransactions(budgetId);
1151
+ // Invalidate cache since import may create new payees
1152
+ this.markNeedsSync(budgetId);
1153
+ return {
1154
+ imported_count: response.data.transaction_ids.length,
1155
+ transaction_ids: response.data.transaction_ids,
1156
+ };
1157
+ }
1158
+ /**
1159
+ * Create a new account
1160
+ */
1161
+ async createAccount(budgetId, name, type, balance) {
1162
+ assertWriteAllowed('create_account');
1163
+ const api = this.getApi();
1164
+ const response = await api.accounts.createAccount(budgetId, {
1165
+ account: {
1166
+ balance,
1167
+ name,
1168
+ type,
1169
+ },
1170
+ });
1171
+ // Invalidate cache since we've added a new account
1172
+ this.markNeedsSync(budgetId);
1173
+ const account = response.data.account;
1174
+ const cache = await this.getLocalBudget(budgetId);
1175
+ return {
1176
+ balance: account.balance,
1177
+ balance_currency: this.toCurrency(account.balance, cache.currencyFormat),
1178
+ cleared_balance: account.cleared_balance,
1179
+ cleared_balance_currency: this.toCurrency(account.cleared_balance, cache.currencyFormat),
1180
+ closed: account.closed,
1181
+ direct_import_in_error: account.direct_import_in_error ?? false,
1182
+ direct_import_linked: account.direct_import_linked ?? false,
1183
+ id: account.id,
1184
+ name: account.name,
1185
+ on_budget: account.on_budget,
1186
+ type: account.type,
1187
+ uncleared_balance: account.uncleared_balance,
1188
+ uncleared_balance_currency: this.toCurrency(account.uncleared_balance, cache.currencyFormat),
1189
+ };
1190
+ }
1191
+ /**
1192
+ * Update category budget for a month
1193
+ */
1194
+ async updateCategoryBudget(budgetId, month, categoryId, budgeted) {
1195
+ assertWriteAllowed('update_category_budget');
1196
+ const cache = await this.getLocalBudget(budgetId);
1197
+ const api = this.getApi();
1198
+ const response = await api.categories.updateMonthCategory(budgetId, month, categoryId, {
1199
+ category: {
1200
+ budgeted,
1201
+ },
1202
+ });
1203
+ const c = response.data.category;
1204
+ return {
1205
+ activity: c.activity,
1206
+ activity_currency: this.toCurrency(c.activity, cache.currencyFormat),
1207
+ balance: c.balance,
1208
+ balance_currency: this.toCurrency(c.balance, cache.currencyFormat),
1209
+ budgeted: c.budgeted,
1210
+ budgeted_currency: this.toCurrency(c.budgeted, cache.currencyFormat),
1211
+ category_group_id: c.category_group_id,
1212
+ category_group_name: cache.categoryGroupNameById.get(c.category_group_id) ?? '',
1213
+ goal_percentage_complete: c.goal_percentage_complete ?? null,
1214
+ goal_target: c.goal_target ?? null,
1215
+ goal_type: c.goal_type ?? null,
1216
+ hidden: c.hidden,
1217
+ id: c.id,
1218
+ name: c.name,
1219
+ };
1220
+ }
1221
+ /**
1222
+ * Clear all local budgets (useful for testing or forcing full re-sync)
1223
+ */
1224
+ clearLocalBudgets() {
1225
+ this.budgets = null;
1226
+ this.localBudgets.clear();
1227
+ this.previousBudgetDetails.clear();
1228
+ }
1229
+ /**
1230
+ * Force sync for a specific budget or all budgets.
1231
+ * If budgetId provided, marks that budget as needing sync.
1232
+ * If no budgetId provided, clears all local budgets (forcing full re-sync on next access).
1233
+ *
1234
+ * @deprecated Use markNeedsSync() for individual budgets. This method
1235
+ * is kept for backwards compatibility but will be removed in a future version.
1236
+ */
1237
+ invalidateCache(budgetId) {
1238
+ if (budgetId !== undefined) {
1239
+ this.markNeedsSync(budgetId);
1240
+ }
1241
+ else {
1242
+ this.localBudgets.clear();
1243
+ this.budgets = null;
1244
+ }
1245
+ }
1246
+ }
1247
+ // Export singleton instance
1248
+ export const ynabClient = new YnabClient();