@profullstack/coinpay 0.9.0 → 0.11.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/CHANGELOG.md +57 -0
- package/README.md +8 -0
- package/bin/coinpay.js +158 -13
- package/package.json +15 -2
- package/src/client.d.ts +10 -0
- package/src/client.js +91 -0
- package/src/finances-commands.js +613 -0
- package/src/finances-report.js +458 -0
- package/src/finances-reports.d.ts +359 -0
- package/src/finances-reports.js +356 -0
- package/src/finances-tui.js +14 -0
- package/src/finances.d.ts +32 -0
- package/src/finances.js +68 -2
- package/src/index.d.ts +1 -0
- package/src/index.js +3 -0
- package/src/pdf.js +151 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finance reports, sync jobs, coverage and the statement library.
|
|
3
|
+
*
|
|
4
|
+
* Every function takes a `CoinPayClient` authenticated with a merchant
|
|
5
|
+
* session. Nothing here computes a total: the server freezes each report's
|
|
6
|
+
* rows and sums them exactly, and these calls fetch what it produced. A
|
|
7
|
+
* download returns bytes, never a JSON-wrapped blob.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function query(params) {
|
|
11
|
+
const search = new URLSearchParams();
|
|
12
|
+
for (const [key, value] of Object.entries(params)) {
|
|
13
|
+
if (value === undefined || value === null || value === '') continue;
|
|
14
|
+
if (Array.isArray(value)) {
|
|
15
|
+
for (const v of value) search.append(key, String(v));
|
|
16
|
+
} else {
|
|
17
|
+
search.set(key, String(value));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const s = search.toString();
|
|
21
|
+
return s ? `?${s}` : '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function headersWith(idempotencyKey) {
|
|
25
|
+
return idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Surface the server's structured error `{ error: { code, message } }` as an Error with `.code`. */
|
|
29
|
+
async function call(client, endpoint, options = {}) {
|
|
30
|
+
try {
|
|
31
|
+
return await client.request(endpoint, options);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
const body = err && err.response && err.response.error;
|
|
34
|
+
if (body && typeof body === 'object') {
|
|
35
|
+
const wrapped = new Error(body.message || err.message);
|
|
36
|
+
wrapped.code = body.code;
|
|
37
|
+
wrapped.status = err.status;
|
|
38
|
+
wrapped.retryable = body.retryable === true;
|
|
39
|
+
wrapped.jobId = body.jobId;
|
|
40
|
+
wrapped.reportId = body.reportId;
|
|
41
|
+
wrapped.retryAfter = body.retryAfter;
|
|
42
|
+
wrapped.response = err.response;
|
|
43
|
+
throw wrapped;
|
|
44
|
+
}
|
|
45
|
+
if (err && err.response && typeof err.response.code === 'string' && !err.code) err.code = err.response.code;
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Connections ──
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Claim a SimpleFIN setup token. The token is single-use; the server checks
|
|
54
|
+
* everything it can before spending it and says plainly when the outcome is
|
|
55
|
+
* unknown (`err.code === 'claim_outcome_unknown'`).
|
|
56
|
+
*/
|
|
57
|
+
export async function connectSimpleFin(client, { setupToken, label, protocolVersion, idempotencyKey } = {}) {
|
|
58
|
+
if (!setupToken || !String(setupToken).trim()) throw new Error('A setup token is required');
|
|
59
|
+
return call(client, '/finances/connections', {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: headersWith(idempotencyKey),
|
|
62
|
+
body: JSON.stringify({ setupToken: String(setupToken).trim(), label, protocolVersion }),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Stop future syncs and remove the credential; history is kept. */
|
|
67
|
+
export async function disconnectFinanceConnection(client, connectionId) {
|
|
68
|
+
return call(client, `/finances/connections/${encodeURIComponent(connectionId)}/disconnect`, { method: 'POST', body: '{}' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What deleting a connection would remove. */
|
|
72
|
+
export async function describeFinanceConnection(client, connectionId) {
|
|
73
|
+
return call(client, `/finances/connections/${encodeURIComponent(connectionId)}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Delete a connection and everything under it. `confirm` must be true. */
|
|
77
|
+
export async function deleteFinanceConnection(client, connectionId, { confirm = false } = {}) {
|
|
78
|
+
if (!confirm) throw new Error('Deleting a connection removes its account data; pass { confirm: true }');
|
|
79
|
+
return call(client, `/finances/connections/${encodeURIComponent(connectionId)}?confirm=yes`, {
|
|
80
|
+
method: 'DELETE',
|
|
81
|
+
headers: { 'X-Confirm-Delete': 'yes' },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Opt a connection in or out of a once-daily background sync. */
|
|
86
|
+
export async function setFinanceSyncConsent(client, connectionId, dailySync) {
|
|
87
|
+
return call(client, `/finances/connections/${encodeURIComponent(connectionId)}/consent`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
body: JSON.stringify({ dailySync: dailySync === true }),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Jobs ──
|
|
94
|
+
|
|
95
|
+
/** Queue a period backfill (`{ period: '2026-Q2' }` or `{ from, to }`). Answers 202 with the job(s). */
|
|
96
|
+
export async function createBackfillJob(client, { connectionId, period, from, to, timezone, idempotencyKey } = {}) {
|
|
97
|
+
return call(client, '/finances/sync-jobs', {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: headersWith(idempotencyKey),
|
|
100
|
+
body: JSON.stringify({ kind: 'backfill', connectionId, period, from, to, timezone }),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Queue a background rolling refresh of one connection. */
|
|
105
|
+
export async function createRefreshJob(client, { connectionId, days, idempotencyKey } = {}) {
|
|
106
|
+
return call(client, '/finances/sync-jobs', {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: headersWith(idempotencyKey),
|
|
109
|
+
body: JSON.stringify({ kind: 'refresh', connectionId, days }),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function getFinanceJob(client, jobId) {
|
|
114
|
+
const data = await call(client, `/finances/jobs/${encodeURIComponent(jobId)}`);
|
|
115
|
+
return data.job;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function listFinanceJobs(client, { limit, connectionId } = {}) {
|
|
119
|
+
const data = await call(client, `/finances/jobs${query({ limit, connection: connectionId })}`);
|
|
120
|
+
return data.jobs || [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function cancelFinanceJob(client, jobId) {
|
|
124
|
+
const data = await call(client, `/finances/jobs/${encodeURIComponent(jobId)}/cancel`, { method: 'POST', body: '{}' });
|
|
125
|
+
return data.job;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const TERMINAL_JOB = new Set(['completed', 'failed', 'cancelled', 'partial']);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Poll a job until it reaches a terminal state or `waiting_for_budget`
|
|
132
|
+
* (which can take hours to clear and is reported, not waited out).
|
|
133
|
+
*/
|
|
134
|
+
export async function waitForFinanceJob(client, jobId, { intervalMs = 3000, timeoutMs = 30 * 60 * 1000, onProgress, sleep } = {}) {
|
|
135
|
+
const started = Date.now();
|
|
136
|
+
const pause = sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
137
|
+
for (;;) {
|
|
138
|
+
const job = await getFinanceJob(client, jobId);
|
|
139
|
+
if (onProgress) onProgress(job);
|
|
140
|
+
if (TERMINAL_JOB.has(job.status) || job.status === 'waiting_for_budget') return job;
|
|
141
|
+
if (Date.now() - started > timeoutMs) return job;
|
|
142
|
+
await pause(intervalMs);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Coverage ──
|
|
147
|
+
|
|
148
|
+
export async function getFinanceCoverage(client, { period, from, to, timezone, accountIds, includeHidden } = {}) {
|
|
149
|
+
return call(client, `/finances/coverage${query({ period, from, to, timezone, account: accountIds, hidden: includeHidden ? 1 : undefined })}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Reports ──
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Create an immutable report revision. Answers 202 with `{ report, job }`;
|
|
156
|
+
* use `waitForFinanceReport` to block until it is ready.
|
|
157
|
+
*/
|
|
158
|
+
export async function createFinanceReport(client, {
|
|
159
|
+
period, from, to, timezone, accountIds, scope, includeHidden, includePendingAppendix, formats, strict, idempotencyKey,
|
|
160
|
+
} = {}) {
|
|
161
|
+
return call(client, '/finances/reports', {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: headersWith(idempotencyKey),
|
|
164
|
+
body: JSON.stringify({ period, from, to, timezone, accountIds, scope, includeHidden, includePendingAppendix, formats, strict }),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function listFinanceReports(client, { limit, offset } = {}) {
|
|
169
|
+
return call(client, `/finances/reports${query({ limit, offset })}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function getFinanceReport(client, reportId) {
|
|
173
|
+
return call(client, `/finances/reports/${encodeURIComponent(reportId)}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function deleteFinanceReport(client, reportId) {
|
|
177
|
+
return call(client, `/finances/reports/${encodeURIComponent(reportId)}`, { method: 'DELETE' });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const TERMINAL_REPORT = new Set(['ready', 'failed', 'superseded', 'deleted']);
|
|
181
|
+
|
|
182
|
+
export async function waitForFinanceReport(client, reportId, { intervalMs = 3000, timeoutMs = 30 * 60 * 1000, onProgress, sleep } = {}) {
|
|
183
|
+
const started = Date.now();
|
|
184
|
+
const pause = sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
185
|
+
for (;;) {
|
|
186
|
+
const data = await getFinanceReport(client, reportId);
|
|
187
|
+
if (onProgress) onProgress(data);
|
|
188
|
+
if (TERMINAL_REPORT.has(data.report.status)) return data;
|
|
189
|
+
if (data.job && data.job.status === 'waiting_for_budget') return data;
|
|
190
|
+
if (Date.now() - started > timeoutMs) return data;
|
|
191
|
+
await pause(intervalMs);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Download one format of a ready report. Resolves to
|
|
197
|
+
* `{ bytes: Uint8Array, contentType, filename, sha256, headers }`.
|
|
198
|
+
*/
|
|
199
|
+
export async function downloadFinanceReport(client, reportId, { format = 'pdf' } = {}) {
|
|
200
|
+
return client.requestBinary(`/finances/reports/${encodeURIComponent(reportId)}/download${query({ format })}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Statements ──
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Import an original PDF. `file` is a Uint8Array/Buffer/Blob of the bytes;
|
|
207
|
+
* nothing is read out of it into the ledger.
|
|
208
|
+
*/
|
|
209
|
+
export async function importFinanceStatement(client, {
|
|
210
|
+
file, filename = 'statement.pdf', accountId, period, from, to, timezone, institutionLabel, cycle, notes,
|
|
211
|
+
} = {}) {
|
|
212
|
+
if (!file) throw new Error('A PDF file is required');
|
|
213
|
+
if (!accountId) throw new Error('accountId is required');
|
|
214
|
+
const form = new FormData();
|
|
215
|
+
const blob = file instanceof Blob ? file : new Blob([file], { type: 'application/pdf' });
|
|
216
|
+
form.append('file', blob, filename);
|
|
217
|
+
form.append('accountId', accountId);
|
|
218
|
+
if (period) form.append('period', period);
|
|
219
|
+
if (from) form.append('from', from);
|
|
220
|
+
if (to) form.append('to', to);
|
|
221
|
+
if (timezone) form.append('timezone', timezone);
|
|
222
|
+
if (institutionLabel) form.append('institutionLabel', institutionLabel);
|
|
223
|
+
if (cycle) form.append('cycle', cycle);
|
|
224
|
+
if (notes) form.append('notes', notes);
|
|
225
|
+
return client.requestForm('/finances/statements', form);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function listFinanceStatements(client, { accountId, period, from, to, timezone, limit } = {}) {
|
|
229
|
+
const data = await call(client, `/finances/statements${query({ account: accountId, period, from, to, timezone, limit })}`);
|
|
230
|
+
return data.statements || [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function getFinanceStatement(client, statementId) {
|
|
234
|
+
return call(client, `/finances/statements/${encodeURIComponent(statementId)}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function downloadFinanceStatement(client, statementId) {
|
|
238
|
+
return client.requestBinary(`/finances/statements/${encodeURIComponent(statementId)}/download`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function deleteFinanceStatement(client, statementId) {
|
|
242
|
+
return call(client, `/finances/statements/${encodeURIComponent(statementId)}`, { method: 'DELETE' });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Enter a statement's balances and check them against one report revision.
|
|
247
|
+
* Amounts are decimal strings in the account currency.
|
|
248
|
+
*/
|
|
249
|
+
export async function reconcileFinanceStatement(client, statementId, {
|
|
250
|
+
reportId, opening, closing, credits, debits, currency, signConvention, acknowledge,
|
|
251
|
+
} = {}) {
|
|
252
|
+
const data = await call(client, `/finances/statements/${encodeURIComponent(statementId)}/reconciliations`, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
body: JSON.stringify({ reportId, opening, closing, credits, debits, currency, signConvention, acknowledge }),
|
|
255
|
+
});
|
|
256
|
+
return data.reconciliation;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Books ──
|
|
260
|
+
|
|
261
|
+
/** Rows awaiting review (default) plus the category vocabularies. */
|
|
262
|
+
export async function listBooksQueue(client, { status, scope, accountId, search, start, end, limit, offset } = {}) {
|
|
263
|
+
return call(client, `/finances/books/queue${query({ status, scope, account: accountId, search, start, end, limit, offset })}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Confirm one row. Omitted fields keep the row's current values. */
|
|
267
|
+
export async function reviewBooksTransaction(client, transactionId, { category, taxCategory, scope, note, createRule } = {}) {
|
|
268
|
+
const data = await call(client, `/finances/books/transactions/${encodeURIComponent(transactionId)}`, {
|
|
269
|
+
method: 'PATCH',
|
|
270
|
+
body: JSON.stringify({ category, taxCategory, scope, note, createRule }),
|
|
271
|
+
});
|
|
272
|
+
return data.transaction;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Confirm many rows; with no category given each row's suggestion is accepted. */
|
|
276
|
+
export async function bulkReviewBooks(client, ids, { category, taxCategory, scope, createRule } = {}) {
|
|
277
|
+
return call(client, '/finances/books/bulk', { method: 'POST', body: JSON.stringify({ ids, category, taxCategory, scope, createRule }) });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Queue an auto-categorisation run over unreviewed rows. Answers with the job. */
|
|
281
|
+
export async function categorizeBooks(client, { useModel = true, onlyUncategorized = false } = {}) {
|
|
282
|
+
return call(client, '/finances/books/categorize', { method: 'POST', body: JSON.stringify({ useModel, onlyUncategorized }) });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function listBooksRules(client) {
|
|
286
|
+
const data = await call(client, '/finances/books/rules');
|
|
287
|
+
return data.rules || [];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function createBooksRule(client, { matchField = 'payee', matchType = 'exact', pattern, category, taxCategory, scope } = {}) {
|
|
291
|
+
const data = await call(client, '/finances/books/rules', { method: 'POST', body: JSON.stringify({ matchField, matchType, pattern, category, taxCategory, scope }) });
|
|
292
|
+
return data.rule;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function deleteBooksRule(client, ruleId) {
|
|
296
|
+
return call(client, `/finances/books/rules/${encodeURIComponent(ruleId)}`, { method: 'DELETE' });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Totals by tax category for a year (`2026`), quarter or month. */
|
|
300
|
+
export async function getBooksSummary(client, { period, from, to, timezone, scope, rows } = {}) {
|
|
301
|
+
return call(client, `/finances/books/summary${query({ period, from, to, timezone, scope, rows: rows ? 1 : undefined })}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The CPA pack as bytes. */
|
|
305
|
+
export async function exportBooks(client, { period, from, to, timezone, scope, format = 'csv' } = {}) {
|
|
306
|
+
return client.requestBinary(`/finances/books/export${query({ period, from, to, timezone, scope, format })}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Raw provider payloads ──
|
|
310
|
+
|
|
311
|
+
export async function listFinancePayloads(client, { connectionId, limit, offset } = {}) {
|
|
312
|
+
return call(client, `/finances/payloads${query({ connection: connectionId, limit, offset })}`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function downloadFinancePayload(client, payloadId) {
|
|
316
|
+
return client.requestBinary(`/finances/payloads/${encodeURIComponent(payloadId)}/download`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Email ──
|
|
320
|
+
|
|
321
|
+
/** Email a ready report: attachments plus an expiring no-login link. */
|
|
322
|
+
export async function sendFinanceReportEmail(client, reportId, { to, formats, message, attach, expiresInDays } = {}) {
|
|
323
|
+
return call(client, `/finances/reports/${encodeURIComponent(reportId)}/send`, {
|
|
324
|
+
method: 'POST',
|
|
325
|
+
body: JSON.stringify({ to, formats, message, attach, expiresInDays }),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Email the CPA pack for a period. `toDate` is the exclusive end for a custom range. */
|
|
330
|
+
export async function sendBooksEmail(client, { to, period, from, toDate, timezone, scope, formats, message, attach, expiresInDays } = {}) {
|
|
331
|
+
return call(client, '/finances/books/send', {
|
|
332
|
+
method: 'POST',
|
|
333
|
+
body: JSON.stringify({ to, period, from, toDate, timezone, scope, formats, message, attach, expiresInDays }),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function getWeeklyDigest(client) {
|
|
338
|
+
const data = await call(client, '/finances/email-schedules');
|
|
339
|
+
return data.schedule;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function setWeeklyDigest(client, { weekdays, hour, timezone, recipients, scope, formats, active } = {}) {
|
|
343
|
+
const data = await call(client, '/finances/email-schedules', {
|
|
344
|
+
method: 'POST',
|
|
345
|
+
body: JSON.stringify({ weekdays, hour, timezone, recipients, scope, formats, active }),
|
|
346
|
+
});
|
|
347
|
+
return data.schedule;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function deleteWeeklyDigest(client) {
|
|
351
|
+
return call(client, '/finances/email-schedules', { method: 'DELETE' });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export async function sendWeeklyDigestNow(client) {
|
|
355
|
+
return call(client, '/finances/email-schedules/send-now', { method: 'POST', body: '{}' });
|
|
356
|
+
}
|
package/src/finances-tui.js
CHANGED
|
@@ -312,6 +312,9 @@ function bankScreen(ui, state, theme) {
|
|
|
312
312
|
{ key: 'org_name', title: 'Institution', width: 22, render: (r) => r.org_name || '—', color: theme.muted },
|
|
313
313
|
{ key: 'name', title: 'Account', min: 14, max: 36 },
|
|
314
314
|
{ key: 'effective_kind', title: 'Kind', width: 10, color: (r) => (r.is_liability ? theme.danger : theme.success) },
|
|
315
|
+
// A trailing * marks a stored correction, so a wrong split is
|
|
316
|
+
// traceable to a guess rather than to someone's decision.
|
|
317
|
+
{ key: 'effective_scope', title: 'Side', width: 10, render: (r) => `${r.effective_scope || '—'}${r.scope_override ? '*' : ''}`, color: (r) => (r.effective_scope === 'business' ? theme.info : theme.muted) },
|
|
315
318
|
{ key: 'display_balance', title: 'Balance', width: 13, align: 'right', render: (r) => money(r.display_balance ?? r.balance ?? 0, r.currency || cur), color: (r) => (r.is_liability ? theme.danger : theme.success) },
|
|
316
319
|
{ key: 'available_balance', title: 'Available', width: 12, align: 'right', render: (r) => (r.available_balance == null ? '—' : money(r.available_balance, r.currency || cur)), color: theme.muted },
|
|
317
320
|
{ key: 'balance_date', title: 'As of', width: 10, render: (r) => shortDate(r.balance_date), color: theme.muted },
|
|
@@ -1003,3 +1006,14 @@ export async function runFinancesTui({ client, baseUrl, token, days = 30, interv
|
|
|
1003
1006
|
}
|
|
1004
1007
|
|
|
1005
1008
|
export { TABS as FINANCE_TABS, WINDOWS as FINANCE_WINDOWS };
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* The screen renderers, in tab order.
|
|
1012
|
+
*
|
|
1013
|
+
* Exported so a frame can be drawn without a client, a token or a terminal:
|
|
1014
|
+
* each takes `(ui, state, theme)` and is a pure function of the snapshot, so
|
|
1015
|
+
* hqtui's renderToText can produce the real screen from a fixture. That is what
|
|
1016
|
+
* the hqtui.com showcase captures, and it is the only way to assert on this
|
|
1017
|
+
* layout at all.
|
|
1018
|
+
*/
|
|
1019
|
+
export { SCREENS as FINANCE_SCREENS };
|
package/src/finances.d.ts
CHANGED
|
@@ -15,11 +15,15 @@ export interface FinanceAccount {
|
|
|
15
15
|
balance_date: string | null;
|
|
16
16
|
kind: AccountKind;
|
|
17
17
|
kind_override: string | null;
|
|
18
|
+
/** Operator correction for which side of the books, or null for the guess. */
|
|
19
|
+
scope_override: string | null;
|
|
18
20
|
is_hidden: boolean;
|
|
19
21
|
last_seen_at: string;
|
|
20
22
|
effective_kind: AccountKind;
|
|
21
23
|
is_liability: boolean;
|
|
22
24
|
display_balance: number | null;
|
|
25
|
+
/** `scope_override` when set, otherwise derived from the account name. */
|
|
26
|
+
effective_scope: AccountScope;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export interface FinanceTransaction {
|
|
@@ -57,8 +61,21 @@ export interface FinanceConnection {
|
|
|
57
61
|
last_sync_error: string | null;
|
|
58
62
|
last_sync_accounts: number | null;
|
|
59
63
|
last_sync_transactions: number | null;
|
|
64
|
+
/** SimpleFIN protocol shape pinned on the credential; null = tested default. */
|
|
65
|
+
protocol_version?: 1 | 2 | null;
|
|
66
|
+
/** When the merchant opted into a once-daily background sync; null = never. */
|
|
67
|
+
sync_consent_at?: string | null;
|
|
68
|
+
next_sync_at?: string | null;
|
|
69
|
+
lifecycle_state?: 'active' | 'reconnect_required' | 'payment_required' | 'disconnected' | string;
|
|
70
|
+
disconnected_at?: string | null;
|
|
60
71
|
}
|
|
61
72
|
|
|
73
|
+
/** Every transaction in a window, paged until exhausted or `max` is hit. */
|
|
74
|
+
export function listAllFinanceTransactions(
|
|
75
|
+
client: CoinPayClient,
|
|
76
|
+
options?: { startDate?: string | Date; endDate?: string | Date; pageSize?: number; max?: number },
|
|
77
|
+
): Promise<{ rows: FinanceTransaction[]; total: number; complete: boolean }>;
|
|
78
|
+
|
|
62
79
|
export interface FinanceSummary {
|
|
63
80
|
windowDays: number;
|
|
64
81
|
totals: Array<{ currency: string; assets: number; liabilities: number; net: number; accounts: number }>;
|
|
@@ -86,6 +103,21 @@ export interface FinanceSeriesPoint {
|
|
|
86
103
|
}
|
|
87
104
|
|
|
88
105
|
export type AccountScope = 'business' | 'personal';
|
|
106
|
+
|
|
107
|
+
export interface UpdateFinanceAccountInput {
|
|
108
|
+
/** Corrected account kind, or null to use the derived guess. */
|
|
109
|
+
kind?: string | null;
|
|
110
|
+
/** Which side of the books, or null to use the name-derived guess. */
|
|
111
|
+
scope?: AccountScope | null;
|
|
112
|
+
/** Drop a closed or duplicate account out of the totals. */
|
|
113
|
+
hidden?: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function updateFinanceAccount(
|
|
117
|
+
client: CoinPayClient,
|
|
118
|
+
accountId: string,
|
|
119
|
+
input: UpdateFinanceAccountInput,
|
|
120
|
+
): Promise<FinanceAccount>;
|
|
89
121
|
export type RecurrenceCadence = 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'annual';
|
|
90
122
|
|
|
91
123
|
/**
|
package/src/finances.js
CHANGED
|
@@ -53,6 +53,37 @@ export async function listFinanceAccounts(client, { includeHidden = false } = {}
|
|
|
53
53
|
return data.accounts || [];
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Every transaction in the window, not just the first page.
|
|
58
|
+
*
|
|
59
|
+
* The API caps a page well below what it will report as `total` (ask for 5000
|
|
60
|
+
* and you get 500), so a single call quietly returns a partial ledger. That is
|
|
61
|
+
* fine for a dashboard and useless for books: a report that silently drops
|
|
62
|
+
* two thirds of the year cannot be reconciled against anything, and nothing in
|
|
63
|
+
* the output would say so.
|
|
64
|
+
*/
|
|
65
|
+
export async function listAllFinanceTransactions(client, { startDate, endDate, pageSize = 500, max = 20000 } = {}) {
|
|
66
|
+
const rows = [];
|
|
67
|
+
let offset = 0;
|
|
68
|
+
let total = 0;
|
|
69
|
+
|
|
70
|
+
for (;;) {
|
|
71
|
+
// Both bounds are forwarded. An end date that was accepted here and
|
|
72
|
+
// dropped on the way to the API would return rows past the window and
|
|
73
|
+
// report the window as complete.
|
|
74
|
+
const page = await listFinanceTransactions(client, { limit: pageSize, offset, startDate, endDate });
|
|
75
|
+
const batch = page?.rows ?? [];
|
|
76
|
+
total = Number(page?.total) || total;
|
|
77
|
+
rows.push(...batch);
|
|
78
|
+
offset += batch.length;
|
|
79
|
+
// Stop on a short page as well as on the count: a `total` that disagrees
|
|
80
|
+
// with what is actually returned must not spin this forever.
|
|
81
|
+
if (!batch.length || rows.length >= total || rows.length >= max) break;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { rows, total: total || rows.length, complete: rows.length >= total };
|
|
85
|
+
}
|
|
86
|
+
|
|
56
87
|
/** The ledger, newest first. Returns `{ rows, total, limit, offset }`. */
|
|
57
88
|
export async function listFinanceTransactions(client, filters = {}) {
|
|
58
89
|
return client.request(
|
|
@@ -69,6 +100,28 @@ export async function listFinanceTransactions(client, filters = {}) {
|
|
|
69
100
|
);
|
|
70
101
|
}
|
|
71
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Correct one account: which side of the books it is on, what kind it is, or
|
|
105
|
+
* whether it counts at all.
|
|
106
|
+
*
|
|
107
|
+
* Every field here is an operator correction stored beside the imported value,
|
|
108
|
+
* never over it, so the next sync re-deriving its guess cannot clobber the
|
|
109
|
+
* correction. Pass `null` for an override to hand the account back to the
|
|
110
|
+
* heuristic. Balances and transactions are deliberately not writable.
|
|
111
|
+
*/
|
|
112
|
+
export async function updateFinanceAccount(client, accountId, { kind, scope, hidden } = {}) {
|
|
113
|
+
const body = {};
|
|
114
|
+
if (kind !== undefined) body.kind_override = kind;
|
|
115
|
+
if (scope !== undefined) body.scope_override = scope;
|
|
116
|
+
if (hidden !== undefined) body.is_hidden = hidden;
|
|
117
|
+
if (Object.keys(body).length === 0) throw new Error('Nothing to update');
|
|
118
|
+
const data = await client.request(`/finances/accounts/${encodeURIComponent(accountId)}`, {
|
|
119
|
+
method: 'PATCH',
|
|
120
|
+
body: JSON.stringify(body),
|
|
121
|
+
});
|
|
122
|
+
return data.account;
|
|
123
|
+
}
|
|
124
|
+
|
|
72
125
|
/** Linked institutions and their last sync outcome. */
|
|
73
126
|
export async function listFinanceConnections(client) {
|
|
74
127
|
return client.request('/finances/connections');
|
|
@@ -293,6 +346,14 @@ export function buildFinanceSnapshot(raw = {}, { days = 30, now = new Date() } =
|
|
|
293
346
|
plaidEnabled: Boolean(raw.connections?.plaidEnabled),
|
|
294
347
|
ledger: raw.transactions?.rows || [],
|
|
295
348
|
ledgerTotal: raw.transactions?.total ?? 0,
|
|
349
|
+
/**
|
|
350
|
+
* Whether the ledger above is the whole window or one page of it.
|
|
351
|
+
*
|
|
352
|
+
* Carried rather than inferred from lengths: a caller cannot tell a short
|
|
353
|
+
* final page from a truncated one, and anything printing a coverage line
|
|
354
|
+
* has to know which it is holding before it makes a claim about the period.
|
|
355
|
+
*/
|
|
356
|
+
ledgerComplete: raw.transactions?.complete ?? ((raw.transactions?.rows?.length ?? 0) >= (raw.transactions?.total ?? 0)),
|
|
296
357
|
};
|
|
297
358
|
|
|
298
359
|
const refundsUsd = round2(card.refundedUsd + escrow.refundedUsd);
|
|
@@ -342,12 +403,17 @@ export function buildFinanceSnapshot(raw = {}, { days = 30, now = new Date() } =
|
|
|
342
403
|
* Fetch everything and build a snapshot. Routes fail independently: a 500
|
|
343
404
|
* from one of them lands in `snapshot.errors[name]` and its section is empty.
|
|
344
405
|
*/
|
|
345
|
-
export async function collectFinanceSnapshot(client, { days = 30, limit = 100, businessId } = {}) {
|
|
406
|
+
export async function collectFinanceSnapshot(client, { days = 30, limit = 100, businessId, allTransactions = false } = {}) {
|
|
346
407
|
const since = Number.isFinite(days) && days > 0 ? new Date(Date.now() - days * 86400000).toISOString() : undefined;
|
|
347
408
|
const sources = {
|
|
348
409
|
summary: () => getFinanceSummary(client, { days }),
|
|
349
410
|
accounts: () => listFinanceAccounts(client),
|
|
350
|
-
|
|
411
|
+
// The dashboard wants one page and wants it now; a report wants the whole
|
|
412
|
+
// window, because a document that silently holds a fifth of the year
|
|
413
|
+
// cannot be reconciled against anything.
|
|
414
|
+
transactions: () => (allTransactions
|
|
415
|
+
? listAllFinanceTransactions(client, { startDate: since })
|
|
416
|
+
: listFinanceTransactions(client, { limit, startDate: since })),
|
|
351
417
|
connections: () => listFinanceConnections(client),
|
|
352
418
|
stats: () => getDashboardStats(client, { businessId }),
|
|
353
419
|
analytics: () => getFinanceAnalytics(client, { period: periodForDays(days), businessId }),
|
package/src/index.d.ts
CHANGED