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.
- package/LICENSE +25 -0
- package/README.md +99 -0
- package/dist/backup.d.ts +28 -0
- package/dist/backup.d.ts.map +1 -0
- package/dist/backup.js +78 -0
- package/dist/drift-detection.d.ts +110 -0
- package/dist/drift-detection.d.ts.map +1 -0
- package/dist/drift-detection.js +366 -0
- package/dist/drift-snapshot.d.ts +74 -0
- package/dist/drift-snapshot.d.ts.map +1 -0
- package/dist/drift-snapshot.js +120 -0
- package/dist/helpers.d.ts +65 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +226 -0
- package/dist/local-budget.d.ts +86 -0
- package/dist/local-budget.d.ts.map +1 -0
- package/dist/local-budget.js +277 -0
- package/dist/logger.d.ts +50 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +136 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1626 -0
- package/dist/sync-history.d.ts +95 -0
- package/dist/sync-history.d.ts.map +1 -0
- package/dist/sync-history.js +205 -0
- package/dist/sync-providers.d.ts +71 -0
- package/dist/sync-providers.d.ts.map +1 -0
- package/dist/sync-providers.js +105 -0
- package/dist/types.d.ts +535 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/ynab-client.d.ts +257 -0
- package/dist/ynab-client.d.ts.map +1 -0
- package/dist/ynab-client.js +1248 -0
- package/package.json +102 -0
|
@@ -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();
|