@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.
@@ -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
+ }
@@ -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
- transactions: () => listFinanceTransactions(client, { limit, startDate: since }),
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
@@ -149,3 +149,4 @@ import { CoinPayClient } from './client.js';
149
149
  export default CoinPayClient;
150
150
 
151
151
  export * from './finances.js';
152
+ export * from './finances-reports.js';
package/src/index.js CHANGED
@@ -344,3 +344,6 @@ export {
344
344
  };
345
345
 
346
346
  export default CoinPayClient;
347
+
348
+ // Finance reports, sync jobs, coverage and the statement library.
349
+ export * from './finances-reports.js';