@hakimelek/monarchmoney 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/dist/client.d.ts +262 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +673 -0
- package/dist/client.js.map +1 -0
- package/dist/endpoints.d.ts +5 -0
- package/dist/endpoints.d.ts.map +1 -0
- package/dist/endpoints.js +5 -0
- package/dist/endpoints.js.map +1 -0
- package/dist/errors.d.ts +37 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +47 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/queries.d.ts +35 -0
- package/dist/queries.d.ts.map +1 -0
- package/dist/queries.js +463 -0
- package/dist/queries.js.map +1 -0
- package/dist/types.d.ts +574 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +60 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as readline from "node:readline";
|
|
4
|
+
import speakeasy from "speakeasy";
|
|
5
|
+
import { getLoginEndpoint, getGraphQL, getAccountBalanceHistoryUploadEndpoint } from "./endpoints.js";
|
|
6
|
+
import { LoginFailedException, RequireMFAException, RequestFailedException } from "./errors.js";
|
|
7
|
+
import * as queries from "./queries.js";
|
|
8
|
+
const SESSION_FILE = ".mm/mm_session.json";
|
|
9
|
+
const DEFAULT_RECORD_LIMIT = 100;
|
|
10
|
+
const USER_AGENT = "monarchmoney-node (https://github.com/hakimelek/monarchmoney-node)";
|
|
11
|
+
export class MonarchMoney {
|
|
12
|
+
_headers;
|
|
13
|
+
_sessionFile;
|
|
14
|
+
_token;
|
|
15
|
+
_timeout;
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
const { sessionFile = SESSION_FILE, timeout = 10, token } = options;
|
|
18
|
+
this._headers = {
|
|
19
|
+
Accept: "application/json",
|
|
20
|
+
"Client-Platform": "web",
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
"User-Agent": USER_AGENT,
|
|
23
|
+
};
|
|
24
|
+
if (token) {
|
|
25
|
+
this._headers["Authorization"] = `Token ${token}`;
|
|
26
|
+
}
|
|
27
|
+
this._sessionFile = path.isAbsolute(sessionFile)
|
|
28
|
+
? sessionFile
|
|
29
|
+
: path.resolve(process.cwd(), sessionFile);
|
|
30
|
+
this._token = token ?? null;
|
|
31
|
+
this._timeout = timeout * 1000;
|
|
32
|
+
}
|
|
33
|
+
/** Timeout for API calls, in seconds. */
|
|
34
|
+
get timeout() {
|
|
35
|
+
return this._timeout / 1000;
|
|
36
|
+
}
|
|
37
|
+
/** Sets the timeout for API calls, in seconds. */
|
|
38
|
+
setTimeout(timeoutSecs) {
|
|
39
|
+
this._timeout = timeoutSecs * 1000;
|
|
40
|
+
}
|
|
41
|
+
/** The current auth token, or `null` if not logged in. */
|
|
42
|
+
get token() {
|
|
43
|
+
return this._token;
|
|
44
|
+
}
|
|
45
|
+
/** Sets the auth token directly (e.g. from an external source). */
|
|
46
|
+
setToken(token) {
|
|
47
|
+
this._token = token;
|
|
48
|
+
this._headers["Authorization"] = `Token ${token}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Logs into Monarch Money.
|
|
52
|
+
*
|
|
53
|
+
* @param email - Account email address.
|
|
54
|
+
* @param password - Account password.
|
|
55
|
+
* @param options.useSavedSession - Load token from disk if available. Default: `true`.
|
|
56
|
+
* @param options.saveSession - Persist token to disk after login. Default: `true`.
|
|
57
|
+
* @param options.mfaSecretKey - TOTP secret for automatic MFA (base32). Bypasses MFA prompt.
|
|
58
|
+
* @throws {RequireMFAException} If MFA is required. Call `multiFactorAuthenticate()` next.
|
|
59
|
+
* @throws {LoginFailedException} If credentials are invalid.
|
|
60
|
+
*/
|
|
61
|
+
async login(email, password, options = {}) {
|
|
62
|
+
const { useSavedSession = true, saveSession = true, mfaSecretKey } = options;
|
|
63
|
+
if (useSavedSession && fs.existsSync(this._sessionFile)) {
|
|
64
|
+
this.loadSession(this._sessionFile);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!email?.trim() || !password?.trim()) {
|
|
68
|
+
throw new LoginFailedException("Email and password are required to login when not using a saved session.");
|
|
69
|
+
}
|
|
70
|
+
await this._loginUser(email, password, mfaSecretKey);
|
|
71
|
+
if (saveSession) {
|
|
72
|
+
this.saveSession(this._sessionFile);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Completes the MFA step after a `RequireMFAException`.
|
|
77
|
+
*
|
|
78
|
+
* @param email - Account email address.
|
|
79
|
+
* @param password - Account password.
|
|
80
|
+
* @param code - The 6-digit TOTP code.
|
|
81
|
+
*/
|
|
82
|
+
async multiFactorAuthenticate(email, password, code) {
|
|
83
|
+
await this._multiFactorAuthenticate(email, password, code);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Interactive CLI login that prompts for email, password, and MFA code if needed.
|
|
87
|
+
*/
|
|
88
|
+
async interactiveLogin(options = {}) {
|
|
89
|
+
const { useSavedSession = true, saveSession = true } = options;
|
|
90
|
+
const rl = readline.createInterface({
|
|
91
|
+
input: process.stdin,
|
|
92
|
+
output: process.stdout,
|
|
93
|
+
});
|
|
94
|
+
const ask = (prompt) => new Promise((resolve) => rl.question(prompt, (answer) => resolve(answer.trim())));
|
|
95
|
+
let email = "";
|
|
96
|
+
let passwd = "";
|
|
97
|
+
try {
|
|
98
|
+
email = await ask("Email: ");
|
|
99
|
+
passwd = await ask("Password: ");
|
|
100
|
+
await this.login(email, passwd, { useSavedSession, saveSession: false });
|
|
101
|
+
if (saveSession)
|
|
102
|
+
this.saveSession(this._sessionFile);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
if (e instanceof RequireMFAException) {
|
|
106
|
+
const code = await ask("Two Factor Code: ");
|
|
107
|
+
await this.multiFactorAuthenticate(email, passwd, code);
|
|
108
|
+
if (saveSession)
|
|
109
|
+
this.saveSession(this._sessionFile);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
throw e;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
rl.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Saves the current session token to disk. */
|
|
120
|
+
saveSession(filename) {
|
|
121
|
+
const file = path.resolve(filename ?? this._sessionFile);
|
|
122
|
+
const dir = path.dirname(file);
|
|
123
|
+
if (!fs.existsSync(dir))
|
|
124
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
125
|
+
fs.writeFileSync(file, JSON.stringify({ token: this._token }), {
|
|
126
|
+
encoding: "utf8",
|
|
127
|
+
mode: 0o600,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** Loads a previously saved session token from disk. */
|
|
131
|
+
loadSession(filename) {
|
|
132
|
+
const file = path.resolve(filename ?? this._sessionFile);
|
|
133
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
134
|
+
const data = JSON.parse(raw);
|
|
135
|
+
if (!data.token) {
|
|
136
|
+
throw new LoginFailedException("Session file does not contain a valid token.");
|
|
137
|
+
}
|
|
138
|
+
this.setToken(data.token);
|
|
139
|
+
}
|
|
140
|
+
/** Deletes the session file from disk. */
|
|
141
|
+
deleteSession(filename) {
|
|
142
|
+
const file = path.resolve(filename ?? this._sessionFile);
|
|
143
|
+
if (fs.existsSync(file))
|
|
144
|
+
fs.unlinkSync(file);
|
|
145
|
+
}
|
|
146
|
+
// ---------- Private auth ----------
|
|
147
|
+
async _loginUser(email, password, mfaSecretKey) {
|
|
148
|
+
const body = {
|
|
149
|
+
username: email,
|
|
150
|
+
password,
|
|
151
|
+
supports_mfa: true,
|
|
152
|
+
trusted_device: false,
|
|
153
|
+
};
|
|
154
|
+
if (mfaSecretKey) {
|
|
155
|
+
body.totp = speakeasy.totp({ secret: mfaSecretKey, encoding: "base32" });
|
|
156
|
+
}
|
|
157
|
+
const res = await fetch(getLoginEndpoint(), {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: this._headers,
|
|
160
|
+
body: JSON.stringify(body),
|
|
161
|
+
signal: AbortSignal.timeout(this._timeout),
|
|
162
|
+
});
|
|
163
|
+
if (res.status === 403) {
|
|
164
|
+
throw new RequireMFAException();
|
|
165
|
+
}
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
let detail = "";
|
|
168
|
+
try {
|
|
169
|
+
const data = (await res.json());
|
|
170
|
+
detail =
|
|
171
|
+
typeof data.detail === "string"
|
|
172
|
+
? data.detail
|
|
173
|
+
: typeof data.error_code === "string"
|
|
174
|
+
? data.error_code
|
|
175
|
+
: "";
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// response body not JSON
|
|
179
|
+
}
|
|
180
|
+
throw new LoginFailedException(detail || `HTTP ${res.status}: ${res.statusText}`, res.status);
|
|
181
|
+
}
|
|
182
|
+
const data = (await res.json());
|
|
183
|
+
this.setToken(data.token);
|
|
184
|
+
}
|
|
185
|
+
async _multiFactorAuthenticate(email, password, code) {
|
|
186
|
+
const body = {
|
|
187
|
+
username: email,
|
|
188
|
+
password,
|
|
189
|
+
supports_mfa: true,
|
|
190
|
+
totp: code,
|
|
191
|
+
trusted_device: false,
|
|
192
|
+
};
|
|
193
|
+
const res = await fetch(getLoginEndpoint(), {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers: this._headers,
|
|
196
|
+
body: JSON.stringify(body),
|
|
197
|
+
signal: AbortSignal.timeout(this._timeout),
|
|
198
|
+
});
|
|
199
|
+
if (!res.ok) {
|
|
200
|
+
let msg = "";
|
|
201
|
+
try {
|
|
202
|
+
const data = (await res.json());
|
|
203
|
+
if (typeof data.detail === "string")
|
|
204
|
+
msg = data.detail;
|
|
205
|
+
else if (typeof data.error_code === "string")
|
|
206
|
+
msg = data.error_code;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// response body not JSON
|
|
210
|
+
}
|
|
211
|
+
throw new LoginFailedException(msg || `HTTP ${res.status}: ${res.statusText}`, res.status);
|
|
212
|
+
}
|
|
213
|
+
const data = (await res.json());
|
|
214
|
+
this.setToken(data.token);
|
|
215
|
+
}
|
|
216
|
+
// ---------- Private GraphQL ----------
|
|
217
|
+
async gqlCall(operation, query, variables = {}) {
|
|
218
|
+
if (!this._token) {
|
|
219
|
+
throw new LoginFailedException("Not authenticated. Call login() first or provide a token.");
|
|
220
|
+
}
|
|
221
|
+
const res = await fetch(getGraphQL(), {
|
|
222
|
+
method: "POST",
|
|
223
|
+
headers: this._headers,
|
|
224
|
+
body: JSON.stringify({ operationName: operation, query, variables }),
|
|
225
|
+
signal: AbortSignal.timeout(this._timeout),
|
|
226
|
+
});
|
|
227
|
+
if (!res.ok) {
|
|
228
|
+
throw new RequestFailedException(`HTTP ${res.status}: ${res.statusText}`, { statusCode: res.status });
|
|
229
|
+
}
|
|
230
|
+
const json = (await res.json());
|
|
231
|
+
if (json.errors?.length) {
|
|
232
|
+
throw new RequestFailedException(json.errors.map((e) => e.message).join("; "), { graphQLErrors: json.errors });
|
|
233
|
+
}
|
|
234
|
+
if (!json.data) {
|
|
235
|
+
throw new RequestFailedException("No data in GraphQL response");
|
|
236
|
+
}
|
|
237
|
+
return json.data;
|
|
238
|
+
}
|
|
239
|
+
// ---------- Date helpers ----------
|
|
240
|
+
_getStartOfCurrentMonth() {
|
|
241
|
+
const d = new Date();
|
|
242
|
+
return new Date(d.getFullYear(), d.getMonth(), 1).toISOString().slice(0, 10);
|
|
243
|
+
}
|
|
244
|
+
_getEndOfCurrentMonth() {
|
|
245
|
+
const d = new Date();
|
|
246
|
+
return new Date(d.getFullYear(), d.getMonth() + 1, 0)
|
|
247
|
+
.toISOString()
|
|
248
|
+
.slice(0, 10);
|
|
249
|
+
}
|
|
250
|
+
// =====================================================================
|
|
251
|
+
// READ METHODS
|
|
252
|
+
// =====================================================================
|
|
253
|
+
/** Gets all accounts linked to Monarch Money. */
|
|
254
|
+
async getAccounts() {
|
|
255
|
+
return this.gqlCall("GetAccounts", queries.GET_ACCOUNTS);
|
|
256
|
+
}
|
|
257
|
+
/** Gets all available account types and their subtypes. */
|
|
258
|
+
async getAccountTypeOptions() {
|
|
259
|
+
return this.gqlCall("GetAccountTypeOptions", queries.GET_ACCOUNT_TYPE_OPTIONS);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Gets daily account balances starting from `startDate`.
|
|
263
|
+
* Defaults to 31 days ago if not specified.
|
|
264
|
+
*/
|
|
265
|
+
async getRecentAccountBalances(startDate) {
|
|
266
|
+
if (!startDate) {
|
|
267
|
+
const d = new Date();
|
|
268
|
+
d.setDate(d.getDate() - 31);
|
|
269
|
+
startDate = d.toISOString().slice(0, 10);
|
|
270
|
+
}
|
|
271
|
+
return this.gqlCall("GetAccountRecentBalances", queries.GET_RECENT_ACCOUNT_BALANCES, { startDate });
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Gets net-value snapshots grouped by account type.
|
|
275
|
+
* @param timeframe - `"year"` or `"month"` granularity.
|
|
276
|
+
*/
|
|
277
|
+
async getAccountSnapshotsByType(startDate, timeframe) {
|
|
278
|
+
return this.gqlCall("GetSnapshotsByAccountType", queries.GET_SNAPSHOTS_BY_ACCOUNT_TYPE, { startDate, timeframe });
|
|
279
|
+
}
|
|
280
|
+
/** Gets daily aggregate net value across all accounts. */
|
|
281
|
+
async getAggregateSnapshots(options) {
|
|
282
|
+
const d = new Date();
|
|
283
|
+
d.setFullYear(d.getFullYear() - 150);
|
|
284
|
+
d.setDate(1);
|
|
285
|
+
return this.gqlCall("GetAggregateSnapshots", queries.GET_AGGREGATE_SNAPSHOTS, {
|
|
286
|
+
filters: {
|
|
287
|
+
startDate: options?.startDate ?? d.toISOString().slice(0, 10),
|
|
288
|
+
endDate: options?.endDate ?? undefined,
|
|
289
|
+
accountType: options?.accountType ?? undefined,
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/** Gets holdings (securities) for a brokerage or investment account. */
|
|
294
|
+
async getAccountHoldings(accountId) {
|
|
295
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
296
|
+
return this.gqlCall("Web_GetHoldings", queries.GET_HOLDINGS, {
|
|
297
|
+
input: {
|
|
298
|
+
accountIds: [String(accountId)],
|
|
299
|
+
endDate: today,
|
|
300
|
+
includeHiddenHoldings: true,
|
|
301
|
+
startDate: today,
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/** Gets daily balance history for a specific account. */
|
|
306
|
+
async getAccountHistory(accountId) {
|
|
307
|
+
const result = await this.gqlCall("AccountDetails_getAccount", queries.GET_ACCOUNT_HISTORY, {
|
|
308
|
+
id: String(accountId),
|
|
309
|
+
});
|
|
310
|
+
return result.snapshots.map((s) => ({
|
|
311
|
+
...s,
|
|
312
|
+
accountId: String(accountId),
|
|
313
|
+
accountName: result.account.displayName,
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
/** Gets linked institutions and their credentials. */
|
|
317
|
+
async getInstitutions() {
|
|
318
|
+
return this.gqlCall("Web_GetInstitutionSettings", queries.GET_INSTITUTIONS);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Gets budgets with actual amounts for the given date range.
|
|
322
|
+
* Defaults to previous month through next month.
|
|
323
|
+
*/
|
|
324
|
+
async getBudgets(startDate, endDate) {
|
|
325
|
+
let start = startDate;
|
|
326
|
+
let end = endDate;
|
|
327
|
+
if (!start && !end) {
|
|
328
|
+
const d = new Date();
|
|
329
|
+
start = new Date(d.getFullYear(), d.getMonth() - 1, 1)
|
|
330
|
+
.toISOString()
|
|
331
|
+
.slice(0, 10);
|
|
332
|
+
end = new Date(d.getFullYear(), d.getMonth() + 2, 0)
|
|
333
|
+
.toISOString()
|
|
334
|
+
.slice(0, 10);
|
|
335
|
+
}
|
|
336
|
+
else if (Boolean(start) !== Boolean(end)) {
|
|
337
|
+
throw new Error("You must specify both startDate and endDate, not just one of them.");
|
|
338
|
+
}
|
|
339
|
+
return this.gqlCall("Common_GetJointPlanningData", queries.GET_BUDGETS, { startDate: start, endDate: end });
|
|
340
|
+
}
|
|
341
|
+
/** Gets Monarch Money subscription details (plan status, trial, etc.). */
|
|
342
|
+
async getSubscriptionDetails() {
|
|
343
|
+
return this.gqlCall("GetSubscriptionDetails", queries.GET_SUBSCRIPTION_DETAILS);
|
|
344
|
+
}
|
|
345
|
+
/** Gets aggregate transaction summary (totals, averages, counts). */
|
|
346
|
+
async getTransactionsSummary() {
|
|
347
|
+
return this.gqlCall("GetTransactionsPage", queries.GET_TRANSACTIONS_SUMMARY);
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Gets transactions with filtering, pagination, and sorting.
|
|
351
|
+
* Defaults to the most recent 100 transactions.
|
|
352
|
+
*/
|
|
353
|
+
async getTransactions(options = {}) {
|
|
354
|
+
const { limit = DEFAULT_RECORD_LIMIT, offset = 0, startDate, endDate, search = "", categoryIds = [], accountIds = [], tagIds = [], hasAttachments, hasNotes, hiddenFromReports, isSplit, isRecurring, importedFromMint, syncedFromInstitution, } = options;
|
|
355
|
+
if (Boolean(startDate) !== Boolean(endDate)) {
|
|
356
|
+
throw new Error("You must specify both startDate and endDate, not just one.");
|
|
357
|
+
}
|
|
358
|
+
const filters = {
|
|
359
|
+
search,
|
|
360
|
+
categories: categoryIds,
|
|
361
|
+
accounts: accountIds,
|
|
362
|
+
tags: tagIds,
|
|
363
|
+
};
|
|
364
|
+
if (hasAttachments != null)
|
|
365
|
+
filters.hasAttachments = hasAttachments;
|
|
366
|
+
if (hasNotes != null)
|
|
367
|
+
filters.hasNotes = hasNotes;
|
|
368
|
+
if (hiddenFromReports != null)
|
|
369
|
+
filters.hideFromReports = hiddenFromReports;
|
|
370
|
+
if (isRecurring != null)
|
|
371
|
+
filters.isRecurring = isRecurring;
|
|
372
|
+
if (isSplit != null)
|
|
373
|
+
filters.isSplit = isSplit;
|
|
374
|
+
if (importedFromMint != null)
|
|
375
|
+
filters.importedFromMint = importedFromMint;
|
|
376
|
+
if (syncedFromInstitution != null)
|
|
377
|
+
filters.syncedFromInstitution = syncedFromInstitution;
|
|
378
|
+
if (startDate && endDate) {
|
|
379
|
+
filters.startDate = startDate;
|
|
380
|
+
filters.endDate = endDate;
|
|
381
|
+
}
|
|
382
|
+
return this.gqlCall("GetTransactionsList", queries.GET_TRANSACTIONS_LIST, { offset, limit, orderBy: "date", filters });
|
|
383
|
+
}
|
|
384
|
+
/** Gets all transaction categories. */
|
|
385
|
+
async getTransactionCategories() {
|
|
386
|
+
return this.gqlCall("GetCategories", queries.GET_CATEGORIES);
|
|
387
|
+
}
|
|
388
|
+
/** Gets all category groups. */
|
|
389
|
+
async getTransactionCategoryGroups() {
|
|
390
|
+
return this.gqlCall("ManageGetCategoryGroups", queries.GET_CATEGORY_GROUPS);
|
|
391
|
+
}
|
|
392
|
+
/** Gets detailed data for a single transaction. */
|
|
393
|
+
async getTransactionDetails(transactionId) {
|
|
394
|
+
return this.gqlCall("GetTransactionDetails", queries.GET_TRANSACTION_DETAILS, { id: transactionId });
|
|
395
|
+
}
|
|
396
|
+
/** Gets the splits for a transaction. */
|
|
397
|
+
async getTransactionSplits(transactionId) {
|
|
398
|
+
return this.gqlCall("GetTransactionSplits", queries.GET_TRANSACTION_SPLITS, { id: transactionId });
|
|
399
|
+
}
|
|
400
|
+
/** Gets all tags configured in the account. */
|
|
401
|
+
async getTransactionTags() {
|
|
402
|
+
return this.gqlCall("GetTransactionTags", queries.GET_TRANSACTION_TAGS);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Gets cashflow data grouped by category, category group, and merchant.
|
|
406
|
+
* Defaults to the current month.
|
|
407
|
+
*/
|
|
408
|
+
async getCashflow(options) {
|
|
409
|
+
if (options && Boolean(options.startDate) !== Boolean(options.endDate)) {
|
|
410
|
+
throw new Error("You must specify both startDate and endDate, not just one.");
|
|
411
|
+
}
|
|
412
|
+
const start = options?.startDate ?? this._getStartOfCurrentMonth();
|
|
413
|
+
const end = options?.endDate ?? this._getEndOfCurrentMonth();
|
|
414
|
+
return this.gqlCall("Web_GetCashFlowPage", queries.GET_CASHFLOW, {
|
|
415
|
+
filters: {
|
|
416
|
+
search: "",
|
|
417
|
+
categories: [],
|
|
418
|
+
accounts: [],
|
|
419
|
+
tags: [],
|
|
420
|
+
startDate: start,
|
|
421
|
+
endDate: end,
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Gets cashflow summary (income, expenses, savings, savings rate).
|
|
427
|
+
* Defaults to the current month.
|
|
428
|
+
*/
|
|
429
|
+
async getCashflowSummary(options) {
|
|
430
|
+
if (options && Boolean(options.startDate) !== Boolean(options.endDate)) {
|
|
431
|
+
throw new Error("You must specify both startDate and endDate, not just one.");
|
|
432
|
+
}
|
|
433
|
+
const start = options?.startDate ?? this._getStartOfCurrentMonth();
|
|
434
|
+
const end = options?.endDate ?? this._getEndOfCurrentMonth();
|
|
435
|
+
return this.gqlCall("Web_GetCashFlowPage", queries.GET_CASHFLOW_SUMMARY, {
|
|
436
|
+
filters: {
|
|
437
|
+
search: "",
|
|
438
|
+
categories: [],
|
|
439
|
+
accounts: [],
|
|
440
|
+
tags: [],
|
|
441
|
+
startDate: start,
|
|
442
|
+
endDate: end,
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Gets upcoming recurring transactions for a date range.
|
|
448
|
+
* Defaults to the current month.
|
|
449
|
+
*/
|
|
450
|
+
async getRecurringTransactions(startDate, endDate) {
|
|
451
|
+
if (Boolean(startDate) !== Boolean(endDate)) {
|
|
452
|
+
throw new Error("You must specify both startDate and endDate, not just one.");
|
|
453
|
+
}
|
|
454
|
+
return this.gqlCall("Web_GetUpcomingRecurringTransactionItems", queries.GET_RECURRING_TRANSACTIONS, {
|
|
455
|
+
startDate: startDate ?? this._getStartOfCurrentMonth(),
|
|
456
|
+
endDate: endDate ?? this._getEndOfCurrentMonth(),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
/** Checks whether a prior account refresh request has completed. */
|
|
460
|
+
async isAccountsRefreshComplete(accountIds) {
|
|
461
|
+
const result = await this.gqlCall("ForceRefreshAccountsQuery", queries.GET_REFRESH_STATUS);
|
|
462
|
+
if (!result.accounts) {
|
|
463
|
+
throw new RequestFailedException("Unable to check refresh status");
|
|
464
|
+
}
|
|
465
|
+
const list = accountIds?.length
|
|
466
|
+
? result.accounts.filter((a) => accountIds.includes(a.id))
|
|
467
|
+
: result.accounts;
|
|
468
|
+
return list.every((a) => !a.hasSyncInProgress);
|
|
469
|
+
}
|
|
470
|
+
// =====================================================================
|
|
471
|
+
// WRITE METHODS
|
|
472
|
+
// =====================================================================
|
|
473
|
+
/** Creates a new manual account. */
|
|
474
|
+
async createManualAccount(params) {
|
|
475
|
+
return this.gqlCall("Web_CreateManualAccount", queries.CREATE_MANUAL_ACCOUNT, {
|
|
476
|
+
input: {
|
|
477
|
+
type: params.accountType,
|
|
478
|
+
subtype: params.accountSubType,
|
|
479
|
+
includeInNetWorth: params.isInNetWorth,
|
|
480
|
+
name: params.accountName,
|
|
481
|
+
displayBalance: params.accountBalance ?? 0,
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
/** Updates an account's settings and/or balance. */
|
|
486
|
+
async updateAccount(accountId, updates) {
|
|
487
|
+
const input = { id: accountId };
|
|
488
|
+
if (updates.accountName != null)
|
|
489
|
+
input.name = updates.accountName;
|
|
490
|
+
if (updates.accountBalance != null)
|
|
491
|
+
input.displayBalance = updates.accountBalance;
|
|
492
|
+
if (updates.accountType != null)
|
|
493
|
+
input.type = updates.accountType;
|
|
494
|
+
if (updates.accountSubType != null)
|
|
495
|
+
input.subtype = updates.accountSubType;
|
|
496
|
+
if (updates.includeInNetWorth != null)
|
|
497
|
+
input.includeInNetWorth = updates.includeInNetWorth;
|
|
498
|
+
if (updates.hideFromSummaryList != null)
|
|
499
|
+
input.hideFromList = updates.hideFromSummaryList;
|
|
500
|
+
if (updates.hideTransactionsFromReports != null)
|
|
501
|
+
input.hideTransactionsFromReports = updates.hideTransactionsFromReports;
|
|
502
|
+
return this.gqlCall("Common_UpdateAccount", queries.UPDATE_ACCOUNT, { input });
|
|
503
|
+
}
|
|
504
|
+
/** Deletes an account by ID. */
|
|
505
|
+
async deleteAccount(accountId) {
|
|
506
|
+
return this.gqlCall("Common_DeleteAccount", queries.DELETE_ACCOUNT, { id: accountId });
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Requests an account balance/transaction refresh. Non-blocking.
|
|
510
|
+
* Use `isAccountsRefreshComplete()` to poll for status.
|
|
511
|
+
*/
|
|
512
|
+
async requestAccountsRefresh(accountIds) {
|
|
513
|
+
const result = await this.gqlCall("Common_ForceRefreshAccountsMutation", queries.FORCE_REFRESH_ACCOUNTS, { input: { accountIds } });
|
|
514
|
+
if (!result.forceRefreshAccounts.success) {
|
|
515
|
+
throw new RequestFailedException(JSON.stringify(result.forceRefreshAccounts.errors));
|
|
516
|
+
}
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Refreshes accounts and polls until complete or timeout.
|
|
521
|
+
* @returns `true` if all accounts refreshed within the timeout, `false` otherwise.
|
|
522
|
+
*/
|
|
523
|
+
async requestAccountsRefreshAndWait(options) {
|
|
524
|
+
const { timeout = 300, delay = 10 } = options ?? {};
|
|
525
|
+
let accountIds = options?.accountIds;
|
|
526
|
+
if (!accountIds) {
|
|
527
|
+
const data = await this.getAccounts();
|
|
528
|
+
accountIds = data.accounts.map((a) => a.id);
|
|
529
|
+
}
|
|
530
|
+
await this.requestAccountsRefresh(accountIds);
|
|
531
|
+
const deadline = Date.now() + timeout * 1000;
|
|
532
|
+
while (Date.now() < deadline) {
|
|
533
|
+
await new Promise((r) => globalThis.setTimeout(r, delay * 1000));
|
|
534
|
+
if (await this.isAccountsRefreshComplete(accountIds))
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
/** Creates a new transaction. */
|
|
540
|
+
async createTransaction(params) {
|
|
541
|
+
return this.gqlCall("Common_CreateTransactionMutation", queries.CREATE_TRANSACTION, {
|
|
542
|
+
input: {
|
|
543
|
+
date: params.date,
|
|
544
|
+
accountId: params.accountId,
|
|
545
|
+
amount: Math.round(params.amount * 100) / 100,
|
|
546
|
+
merchantName: params.merchantName,
|
|
547
|
+
categoryId: params.categoryId,
|
|
548
|
+
notes: params.notes ?? "",
|
|
549
|
+
shouldUpdateBalance: params.updateBalance ?? false,
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Updates an existing transaction. Only provided fields are changed.
|
|
555
|
+
*/
|
|
556
|
+
async updateTransaction(transactionId, updates) {
|
|
557
|
+
const input = { id: transactionId };
|
|
558
|
+
if (updates.categoryId != null)
|
|
559
|
+
input.category = updates.categoryId;
|
|
560
|
+
if (updates.merchantName != null)
|
|
561
|
+
input.name = updates.merchantName;
|
|
562
|
+
if (updates.goalId != null)
|
|
563
|
+
input.goalId = updates.goalId;
|
|
564
|
+
if (updates.amount != null)
|
|
565
|
+
input.amount = updates.amount;
|
|
566
|
+
if (updates.date != null)
|
|
567
|
+
input.date = updates.date;
|
|
568
|
+
if (updates.hideFromReports != null)
|
|
569
|
+
input.hideFromReports = updates.hideFromReports;
|
|
570
|
+
if (updates.needsReview != null)
|
|
571
|
+
input.needsReview = updates.needsReview;
|
|
572
|
+
if (updates.notes != null)
|
|
573
|
+
input.notes = updates.notes;
|
|
574
|
+
return this.gqlCall("Web_TransactionDrawerUpdateTransaction", queries.UPDATE_TRANSACTION, { input });
|
|
575
|
+
}
|
|
576
|
+
/** Deletes a transaction by ID. */
|
|
577
|
+
async deleteTransaction(transactionId) {
|
|
578
|
+
const result = await this.gqlCall("Common_DeleteTransactionMutation", queries.DELETE_TRANSACTION, { input: { id: transactionId } });
|
|
579
|
+
if (result.deleteTransaction.errors?.length) {
|
|
580
|
+
throw new RequestFailedException(JSON.stringify(result.deleteTransaction.errors));
|
|
581
|
+
}
|
|
582
|
+
return result.deleteTransaction.deleted;
|
|
583
|
+
}
|
|
584
|
+
/** Deletes a transaction category. Optionally moves transactions to another category. */
|
|
585
|
+
async deleteTransactionCategory(categoryId, moveToCategoryId) {
|
|
586
|
+
const result = await this.gqlCall("Web_DeleteCategory", queries.DELETE_CATEGORY, { id: categoryId, moveToCategoryId });
|
|
587
|
+
if (!result.deleteCategory.deleted &&
|
|
588
|
+
result.deleteCategory.errors?.length) {
|
|
589
|
+
throw new RequestFailedException(JSON.stringify(result.deleteCategory.errors));
|
|
590
|
+
}
|
|
591
|
+
return result.deleteCategory.deleted;
|
|
592
|
+
}
|
|
593
|
+
/** Deletes multiple transaction categories. Returns results per category. */
|
|
594
|
+
async deleteTransactionCategories(categoryIds) {
|
|
595
|
+
return Promise.all(categoryIds.map((id) => this.deleteTransactionCategory(id).catch((e) => e instanceof Error ? e : new Error(String(e)))));
|
|
596
|
+
}
|
|
597
|
+
/** Creates a new transaction category within a category group. */
|
|
598
|
+
async createTransactionCategory(params) {
|
|
599
|
+
const d = new Date();
|
|
600
|
+
d.setDate(1);
|
|
601
|
+
return this.gqlCall("Web_CreateCategory", queries.CREATE_CATEGORY, {
|
|
602
|
+
input: {
|
|
603
|
+
groupId: params.groupId,
|
|
604
|
+
name: params.name,
|
|
605
|
+
icon: params.icon ?? "\u2753",
|
|
606
|
+
rolloverStartMonth: params.rolloverStartMonth ?? d.toISOString().slice(0, 10),
|
|
607
|
+
rolloverEnabled: params.rolloverEnabled ?? false,
|
|
608
|
+
rolloverType: params.rolloverType ?? "monthly",
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
/** Creates a new tag for transactions. */
|
|
613
|
+
async createTransactionTag(name, color) {
|
|
614
|
+
return this.gqlCall("Common_CreateTransactionTag", queries.CREATE_TRANSACTION_TAG, { input: { name, color } });
|
|
615
|
+
}
|
|
616
|
+
/** Sets (replaces) all tags on a transaction. */
|
|
617
|
+
async setTransactionTags(transactionId, tagIds) {
|
|
618
|
+
return this.gqlCall("Web_SetTransactionTags", queries.SET_TRANSACTION_TAGS, { input: { transactionId, tagIds } });
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Creates, modifies, or removes splits on a transaction.
|
|
622
|
+
* Pass an empty array to remove all splits.
|
|
623
|
+
* The sum of split amounts must equal the transaction amount.
|
|
624
|
+
*/
|
|
625
|
+
async updateTransactionSplits(transactionId, splitData) {
|
|
626
|
+
return this.gqlCall("Common_SplitTransactionMutation", queries.SPLIT_TRANSACTION, { input: { transactionId, splitData } });
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Sets a budget amount for a category or category group.
|
|
630
|
+
* A zero amount clears the budget. Exactly one of `categoryId` or `categoryGroupId` is required.
|
|
631
|
+
*/
|
|
632
|
+
async setBudgetAmount(params) {
|
|
633
|
+
const { categoryId, categoryGroupId } = params;
|
|
634
|
+
if ((categoryId == null) === (categoryGroupId == null)) {
|
|
635
|
+
throw new Error("You must specify either categoryId OR categoryGroupId; not both.");
|
|
636
|
+
}
|
|
637
|
+
return this.gqlCall("Common_UpdateBudgetItem", queries.UPDATE_BUDGET_ITEM, {
|
|
638
|
+
input: {
|
|
639
|
+
startDate: params.startDate ?? this._getStartOfCurrentMonth(),
|
|
640
|
+
timeframe: params.timeframe ?? "month",
|
|
641
|
+
categoryId: categoryId ?? undefined,
|
|
642
|
+
categoryGroupId: categoryGroupId ?? undefined,
|
|
643
|
+
amount: params.amount,
|
|
644
|
+
applyToFuture: params.applyToFuture ?? false,
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Uploads a CSV file of balance history for a manual account.
|
|
650
|
+
* @param accountId - The account to apply history to.
|
|
651
|
+
* @param csvContent - CSV string content.
|
|
652
|
+
*/
|
|
653
|
+
async uploadAccountBalanceHistory(accountId, csvContent) {
|
|
654
|
+
if (!accountId?.trim() || !csvContent?.trim()) {
|
|
655
|
+
throw new RequestFailedException("accountId and csvContent cannot be empty");
|
|
656
|
+
}
|
|
657
|
+
const form = new FormData();
|
|
658
|
+
form.append("files", new Blob([csvContent], { type: "text/csv" }), "upload.csv");
|
|
659
|
+
form.append("account_files_mapping", JSON.stringify({ "upload.csv": accountId }));
|
|
660
|
+
const headers = { ...this._headers };
|
|
661
|
+
delete headers["Content-Type"];
|
|
662
|
+
const res = await fetch(getAccountBalanceHistoryUploadEndpoint(), {
|
|
663
|
+
method: "POST",
|
|
664
|
+
headers,
|
|
665
|
+
body: form,
|
|
666
|
+
signal: AbortSignal.timeout(this._timeout),
|
|
667
|
+
});
|
|
668
|
+
if (!res.ok) {
|
|
669
|
+
throw new RequestFailedException(`HTTP ${res.status}: ${res.statusText}`, { statusCode: res.status });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
//# sourceMappingURL=client.js.map
|