@profullstack/coinpay 0.9.0 → 0.10.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 +38 -0
- package/README.md +8 -0
- package/bin/coinpay.js +140 -13
- package/package.json +15 -2
- package/src/client.d.ts +10 -0
- package/src/client.js +91 -0
- package/src/finances-commands.js +423 -0
- package/src/finances-report.js +458 -0
- package/src/finances-reports.d.ts +243 -0
- package/src/finances-reports.js +257 -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,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `coinpay finances` subcommands for reports, backfills and statements.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of bin/coinpay.js so the contract is testable without spawning
|
|
5
|
+
* the whole CLI. `runFinancesCommand` throws `CliExit` with one of the
|
|
6
|
+
* documented codes; the binary maps that to `process.exit`.
|
|
7
|
+
*
|
|
8
|
+
* Exit codes for these subcommands:
|
|
9
|
+
* 0 completed, or a job was accepted and reported
|
|
10
|
+
* 2 invalid input (bad flags, bad dates, missing values)
|
|
11
|
+
* 3 strict completeness rejection
|
|
12
|
+
* 4 authentication / authorization
|
|
13
|
+
* 5 provider, storage or execution failure
|
|
14
|
+
*
|
|
15
|
+
* In `--json` mode stdout carries only the result; progress and warnings
|
|
16
|
+
* go to stderr. A queued job is reported as a job, never as a pretend file.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdtempSync, writeFileSync, renameSync, existsSync, rmSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, resolve as resolvePath, basename } from 'node:path';
|
|
21
|
+
import * as api from './finances-reports.js';
|
|
22
|
+
|
|
23
|
+
export const EXIT = { OK: 0, INVALID: 2, STRICT: 3, AUTH: 4, FAILURE: 5 };
|
|
24
|
+
|
|
25
|
+
export class CliExit extends Error {
|
|
26
|
+
constructor(code, message = '') {
|
|
27
|
+
super(message);
|
|
28
|
+
this.exitCode = code;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const COVERAGE_CODES = new Set(['provider_coverage_partial', 'provider_coverage_unknown', 'export_incomplete', 'export_count_mismatch']);
|
|
33
|
+
|
|
34
|
+
function exitCodeFor(err) {
|
|
35
|
+
if (err instanceof CliExit) return err.exitCode;
|
|
36
|
+
if (err && (err.status === 401 || err.status === 403)) return EXIT.AUTH;
|
|
37
|
+
if (err && COVERAGE_CODES.has(err.code)) return EXIT.STRICT;
|
|
38
|
+
if (err && (err.code === 'invalid_period' || err.code === 'invalid_request' || err.code === 'invalid_timezone' || err.code === 'timezone_required' || err.status === 400)) return EXIT.INVALID;
|
|
39
|
+
if (err && err.status === 404) return EXIT.INVALID;
|
|
40
|
+
return EXIT.FAILURE;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function listFlag(value) {
|
|
44
|
+
if (value === undefined || value === null || value === true) return [];
|
|
45
|
+
const values = Array.isArray(value) ? value : [value];
|
|
46
|
+
return values.flatMap((v) => String(v).split(',')).map((s) => s.trim()).filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function periodSelection(flags) {
|
|
50
|
+
const period = typeof flags.period === 'string' ? flags.period : null;
|
|
51
|
+
const from = typeof flags.from === 'string' ? flags.from : null;
|
|
52
|
+
const to = typeof flags.to === 'string' ? flags.to : null;
|
|
53
|
+
if (period && (from || to)) throw new CliExit(EXIT.INVALID, 'Use --period or --from/--to, not both');
|
|
54
|
+
if (!period && !(from && to)) throw new CliExit(EXIT.INVALID, 'Pass --period 2026-08 (or 2026-Q2), or --from YYYY-MM-DD --to YYYY-MM-DD (--to is exclusive)');
|
|
55
|
+
if (period && !/^\d{4}-(\d{2}|Q[1-4])$/i.test(period)) throw new CliExit(EXIT.INVALID, `--period must look like 2026-08 or 2026-Q2 (got ${period})`);
|
|
56
|
+
const iso = /^\d{4}-\d{2}-\d{2}$/;
|
|
57
|
+
if (from && !iso.test(from)) throw new CliExit(EXIT.INVALID, `--from must be YYYY-MM-DD (got ${from})`);
|
|
58
|
+
if (to && !iso.test(to)) throw new CliExit(EXIT.INVALID, `--to must be YYYY-MM-DD (got ${to})`);
|
|
59
|
+
if (from && to && to <= from) throw new CliExit(EXIT.INVALID, '--to is exclusive and must be after --from');
|
|
60
|
+
const timezone = typeof flags.timezone === 'string' ? flags.timezone : typeof flags.tz === 'string' ? flags.tz : undefined;
|
|
61
|
+
// Bounded repeat (an IANA name has at most three segments) so the check on
|
|
62
|
+
// a user-supplied flag carries no nested unbounded quantifier.
|
|
63
|
+
if (timezone !== undefined && (timezone.length > 64 || !/^(UTC|[A-Za-z]{1,32}(?:\/[A-Za-z0-9_+-]{1,32}){1,3})$/.test(timezone))) {
|
|
64
|
+
throw new CliExit(EXIT.INVALID, `--timezone must be an IANA name such as America/Los_Angeles (got ${timezone})`);
|
|
65
|
+
}
|
|
66
|
+
return { period: period || undefined, from: from || undefined, to: to || undefined, timezone };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Write bytes to `target` atomically with owner-only permissions. Refuses
|
|
71
|
+
* to overwrite unless `overwrite` is set.
|
|
72
|
+
*/
|
|
73
|
+
export function writeDownload(target, bytes, { overwrite = false } = {}) {
|
|
74
|
+
const full = resolvePath(target);
|
|
75
|
+
if (existsSync(full) && !overwrite) {
|
|
76
|
+
throw new CliExit(EXIT.INVALID, `${full} already exists; pass --overwrite to replace it`);
|
|
77
|
+
}
|
|
78
|
+
const dir = dirname(full);
|
|
79
|
+
const tmpDir = mkdtempSync(resolvePath(dir, `.coinpay-${basename(full)}-`));
|
|
80
|
+
const tmp = resolvePath(tmpDir, 'download');
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(tmp, bytes, { mode: 0o600 });
|
|
83
|
+
renameSync(tmp, full);
|
|
84
|
+
} finally {
|
|
85
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
return full;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function readSecretFromStdin() {
|
|
91
|
+
const chunks = [];
|
|
92
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
93
|
+
return Buffer.concat(chunks).toString('utf8').trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function promptSecret(message) {
|
|
97
|
+
if (!process.stdin.isTTY) throw new CliExit(EXIT.INVALID, 'No terminal to prompt on; pass --setup-token-stdin and pipe the token in');
|
|
98
|
+
try {
|
|
99
|
+
const { password } = await import('@inquirer/prompts');
|
|
100
|
+
return (await password({ message, mask: '*' })).trim();
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err instanceof CliExit) throw err;
|
|
103
|
+
throw new CliExit(EXIT.INVALID, 'Interactive prompt unavailable; pass --setup-token-stdin and pipe the token in');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function describeJob(job) {
|
|
108
|
+
const p = job.progress || {};
|
|
109
|
+
const done = Array.isArray(p.windowsDone) ? p.windowsDone.length : 0;
|
|
110
|
+
const planned = job.params && job.params.requestsPlanned ? job.params.requestsPlanned : null;
|
|
111
|
+
const parts = [`${job.kind} ${job.id}`, job.status];
|
|
112
|
+
if (planned !== null) parts.push(`${done}/${planned} windows`);
|
|
113
|
+
if (job.nextAttemptAt) parts.push(`next attempt ${job.nextAttemptAt}`);
|
|
114
|
+
if (job.errorMessage) parts.push(job.errorMessage);
|
|
115
|
+
return parts.join(' · ');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function describeReport(report) {
|
|
119
|
+
const parts = [`report ${report.id} r${report.revision}`, report.status, report.period && report.period.label];
|
|
120
|
+
if (report.provider_coverage) parts.push(`coverage ${report.provider_coverage}`);
|
|
121
|
+
if (report.rowCount !== null && report.rowCount !== undefined) parts.push(`${report.rowCount} posted rows`);
|
|
122
|
+
if (report.errorMessage) parts.push(report.errorMessage);
|
|
123
|
+
return parts.filter(Boolean).join(' · ');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run one subcommand. `ctx` supplies the client and the output channels.
|
|
128
|
+
* @returns {Promise<number>} an exit code
|
|
129
|
+
*/
|
|
130
|
+
export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
131
|
+
const { client, out, err: errOut } = ctx;
|
|
132
|
+
const json = flags.json === true;
|
|
133
|
+
const progress = (msg) => { if (json) errOut(msg); else out(msg); };
|
|
134
|
+
const warn = (msg) => errOut(`warning: ${msg}`);
|
|
135
|
+
const emit = (data, text) => { if (json) out(JSON.stringify(data, null, 2)); else if (text) out(text); };
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
switch (subcommand) {
|
|
139
|
+
case 'connect': {
|
|
140
|
+
const provider = typeof flags.provider === 'string' ? flags.provider : 'simplefin';
|
|
141
|
+
if (provider !== 'simplefin') throw new CliExit(EXIT.INVALID, 'Only --provider simplefin can be connected from the CLI; Plaid links in the browser');
|
|
142
|
+
if (flags['setup-token'] !== undefined) {
|
|
143
|
+
throw new CliExit(EXIT.INVALID, 'Refusing --setup-token on the command line (it lands in shell history). Use --setup-token-stdin or the prompt.');
|
|
144
|
+
}
|
|
145
|
+
const token = flags['setup-token-stdin'] ? await readSecretFromStdin() : await promptSecret('SimpleFIN setup token (input hidden)');
|
|
146
|
+
if (!token) throw new CliExit(EXIT.INVALID, 'No setup token provided');
|
|
147
|
+
const protocolVersion = flags['protocol-version'] !== undefined ? Number(flags['protocol-version']) : undefined;
|
|
148
|
+
if (protocolVersion !== undefined && protocolVersion !== 1 && protocolVersion !== 2) throw new CliExit(EXIT.INVALID, '--protocol-version must be 1 or 2');
|
|
149
|
+
progress('Claiming the setup token…');
|
|
150
|
+
const data = await api.connectSimpleFin(client, {
|
|
151
|
+
setupToken: token,
|
|
152
|
+
label: typeof flags.label === 'string' ? flags.label : undefined,
|
|
153
|
+
protocolVersion,
|
|
154
|
+
idempotencyKey: typeof flags['idempotency-key'] === 'string' ? flags['idempotency-key'] : undefined,
|
|
155
|
+
});
|
|
156
|
+
emit(data, `Linked connection ${data.connection.id}${data.connection.label ? ` (${data.connection.label})` : ''}. Run: coinpay finances sync`);
|
|
157
|
+
return EXIT.OK;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case 'disconnect': {
|
|
161
|
+
const id = args[0];
|
|
162
|
+
if (!id) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances disconnect <connection-id> --yes');
|
|
163
|
+
if (!flags.yes) throw new CliExit(EXIT.INVALID, 'This stops future syncs and removes the stored credential (history is kept). Re-run with --yes to confirm.');
|
|
164
|
+
const data = await api.disconnectFinanceConnection(client, id);
|
|
165
|
+
emit(data, `Disconnected ${id}. ${data.note}`);
|
|
166
|
+
return EXIT.OK;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
case 'consent': {
|
|
170
|
+
const id = args[0];
|
|
171
|
+
const mode = args[1];
|
|
172
|
+
if (!id || (mode !== 'on' && mode !== 'off')) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances consent <connection-id> on|off');
|
|
173
|
+
const data = await api.setFinanceSyncConsent(client, id, mode === 'on');
|
|
174
|
+
emit(data, mode === 'on' ? `Daily background sync enabled for ${id} (next: ${data.connection.next_sync_at})` : `Daily background sync disabled for ${id}`);
|
|
175
|
+
return EXIT.OK;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case 'backfill': {
|
|
179
|
+
const sel = periodSelection(flags);
|
|
180
|
+
const connectionId = typeof flags.connection === 'string' ? flags.connection : typeof flags['connection-id'] === 'string' ? flags['connection-id'] : undefined;
|
|
181
|
+
const data = await api.createBackfillJob(client, { ...sel, connectionId, idempotencyKey: typeof flags['idempotency-key'] === 'string' ? flags['idempotency-key'] : undefined });
|
|
182
|
+
let jobs = data.jobs || [data.job];
|
|
183
|
+
for (const job of jobs) progress(`Queued ${describeJob(job)} (${job.params.requestsPlanned} provider request(s) planned)`);
|
|
184
|
+
if (flags.wait) {
|
|
185
|
+
const finished = [];
|
|
186
|
+
for (const job of jobs) {
|
|
187
|
+
const result = await api.waitForFinanceJob(client, job.id, { onProgress: (j) => progress(describeJob(j)) });
|
|
188
|
+
finished.push(result);
|
|
189
|
+
}
|
|
190
|
+
jobs = finished;
|
|
191
|
+
}
|
|
192
|
+
emit({ jobs }, jobs.map(describeJob).join('\n'));
|
|
193
|
+
return jobs.some((j) => j.status === 'failed') ? EXIT.FAILURE : EXIT.OK;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case 'jobs': {
|
|
197
|
+
const action = args[0] || 'list';
|
|
198
|
+
if (action === 'get') {
|
|
199
|
+
const id = args[1];
|
|
200
|
+
if (!id) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances jobs get <job-id>');
|
|
201
|
+
const job = flags.wait ? await api.waitForFinanceJob(client, id, { onProgress: (j) => progress(describeJob(j)) }) : await api.getFinanceJob(client, id);
|
|
202
|
+
emit({ job }, describeJob(job));
|
|
203
|
+
return job.status === 'failed' ? EXIT.FAILURE : EXIT.OK;
|
|
204
|
+
}
|
|
205
|
+
if (action === 'cancel') {
|
|
206
|
+
const id = args[1];
|
|
207
|
+
if (!id) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances jobs cancel <job-id>');
|
|
208
|
+
const job = await api.cancelFinanceJob(client, id);
|
|
209
|
+
emit({ job }, describeJob(job));
|
|
210
|
+
return EXIT.OK;
|
|
211
|
+
}
|
|
212
|
+
if (action === 'list') {
|
|
213
|
+
const jobs = await api.listFinanceJobs(client, { limit: flags.limit ? Number(flags.limit) : undefined, connectionId: typeof flags.connection === 'string' ? flags.connection : undefined });
|
|
214
|
+
emit({ jobs }, jobs.length ? jobs.map(describeJob).join('\n') : 'No jobs yet.');
|
|
215
|
+
return EXIT.OK;
|
|
216
|
+
}
|
|
217
|
+
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances jobs [list|get <id>|cancel <id>]');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
case 'coverage': {
|
|
221
|
+
const sel = periodSelection(flags);
|
|
222
|
+
const data = await api.getFinanceCoverage(client, { ...sel, accountIds: listFlag(flags.account), includeHidden: flags.hidden === true });
|
|
223
|
+
emit(
|
|
224
|
+
data,
|
|
225
|
+
[`${data.period.label} (${data.period.timezone}) · provider coverage: ${data.provider_coverage}`]
|
|
226
|
+
.concat(data.accounts.map((a) => ` ${a.name || a.accountId} ${a.coverage} ${Math.round(a.fraction * 100)}%${a.capped ? ' capped' : ''}${a.gaps.length ? ` ${a.gaps.length} gap(s)` : ''}`))
|
|
227
|
+
.join('\n'),
|
|
228
|
+
);
|
|
229
|
+
return EXIT.OK;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
case 'report': {
|
|
233
|
+
const sel = periodSelection(flags);
|
|
234
|
+
const format = typeof flags.format === 'string' ? flags.format.toLowerCase() : 'pdf';
|
|
235
|
+
if (!['pdf', 'html', 'csv', 'json'].includes(format)) throw new CliExit(EXIT.INVALID, '--format must be pdf, html, csv or json');
|
|
236
|
+
const scope = typeof flags.scope === 'string' ? flags.scope : 'all';
|
|
237
|
+
if (!['all', 'business', 'personal'].includes(scope)) throw new CliExit(EXIT.INVALID, '--scope must be all, business or personal');
|
|
238
|
+
const output = typeof flags.output === 'string' ? flags.output : null;
|
|
239
|
+
const wait = flags.wait === true || output !== null;
|
|
240
|
+
const strict = flags.strict === true;
|
|
241
|
+
|
|
242
|
+
const created = await api.createFinanceReport(client, {
|
|
243
|
+
...sel,
|
|
244
|
+
accountIds: listFlag(flags.account),
|
|
245
|
+
scope,
|
|
246
|
+
includeHidden: flags.hidden === true,
|
|
247
|
+
includePendingAppendix: flags['no-pending'] ? false : true,
|
|
248
|
+
formats: [format, 'json'],
|
|
249
|
+
strict,
|
|
250
|
+
idempotencyKey: typeof flags['idempotency-key'] === 'string' ? flags['idempotency-key'] : undefined,
|
|
251
|
+
});
|
|
252
|
+
let report = created.report;
|
|
253
|
+
progress(`Queued ${describeReport(report)}`);
|
|
254
|
+
if (!wait) {
|
|
255
|
+
emit({ report, job: created.job }, `${describeReport(report)}\nCheck with: coinpay finances jobs get ${created.job.id}`);
|
|
256
|
+
return EXIT.OK;
|
|
257
|
+
}
|
|
258
|
+
const finished = await api.waitForFinanceReport(client, report.id, { onProgress: (d) => progress(describeReport(d.report)) });
|
|
259
|
+
report = finished.report;
|
|
260
|
+
if (report.status !== 'ready') {
|
|
261
|
+
if (finished.job && finished.job.status === 'waiting_for_budget') {
|
|
262
|
+
emit({ report, job: finished.job }, `Report generation is waiting for the provider request budget; next attempt ${finished.job.nextAttemptAt}`);
|
|
263
|
+
return EXIT.FAILURE;
|
|
264
|
+
}
|
|
265
|
+
const code = COVERAGE_CODES.has(report.errorCode) ? EXIT.STRICT : EXIT.FAILURE;
|
|
266
|
+
emit({ report, job: finished.job }, `Report ${report.status}: ${report.errorMessage || report.errorCode || 'unknown'}`);
|
|
267
|
+
return code;
|
|
268
|
+
}
|
|
269
|
+
for (const w of report.warnings || []) warn(w);
|
|
270
|
+
if (report.local_export_complete !== true) {
|
|
271
|
+
emit({ report }, 'Report is missing part of its local export; refusing to present it as complete');
|
|
272
|
+
return EXIT.STRICT;
|
|
273
|
+
}
|
|
274
|
+
if (strict && report.provider_coverage !== 'available_window_fetched') {
|
|
275
|
+
emit({ report }, `--strict: provider coverage is ${report.provider_coverage}`);
|
|
276
|
+
return EXIT.STRICT;
|
|
277
|
+
}
|
|
278
|
+
if (report.provider_coverage !== 'available_window_fetched') {
|
|
279
|
+
warn(`History may be incomplete: provider coverage is ${report.provider_coverage}. ${report.notice}`);
|
|
280
|
+
}
|
|
281
|
+
if (output) {
|
|
282
|
+
const file = await api.downloadFinanceReport(client, report.id, { format });
|
|
283
|
+
const path = writeDownload(output, Buffer.from(file.bytes), { overwrite: flags.overwrite === true });
|
|
284
|
+
emit({ report, output: path, bytes: file.bytes.length, sha256: file.sha256 }, `Wrote ${path} (${file.bytes.length} bytes, ${format}). ${report.notice}`);
|
|
285
|
+
} else {
|
|
286
|
+
emit({ report }, describeReport(report));
|
|
287
|
+
}
|
|
288
|
+
return EXIT.OK;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
case 'reports': {
|
|
292
|
+
const action = args[0] || 'list';
|
|
293
|
+
if (action === 'list') {
|
|
294
|
+
const data = await api.listFinanceReports(client, { limit: flags.limit ? Number(flags.limit) : undefined });
|
|
295
|
+
emit(data, data.reports.length ? data.reports.map(describeReport).join('\n') : 'No reports yet.');
|
|
296
|
+
return EXIT.OK;
|
|
297
|
+
}
|
|
298
|
+
if (action === 'get' || action === 'download' || action === 'delete') {
|
|
299
|
+
const id = args[1];
|
|
300
|
+
if (!id) throw new CliExit(EXIT.INVALID, `Usage: coinpay finances reports ${action} <report-id>`);
|
|
301
|
+
if (action === 'delete') {
|
|
302
|
+
if (!flags.yes) throw new CliExit(EXIT.INVALID, 'Deleting removes the report and its files (not bank data). Re-run with --yes.');
|
|
303
|
+
const data = await api.deleteFinanceReport(client, id);
|
|
304
|
+
emit(data, data.note);
|
|
305
|
+
return EXIT.OK;
|
|
306
|
+
}
|
|
307
|
+
const data = await api.getFinanceReport(client, id);
|
|
308
|
+
if (action === 'get') {
|
|
309
|
+
emit(data, describeReport(data.report));
|
|
310
|
+
return EXIT.OK;
|
|
311
|
+
}
|
|
312
|
+
const format = typeof flags.format === 'string' ? flags.format.toLowerCase() : 'pdf';
|
|
313
|
+
const output = typeof flags.output === 'string' ? flags.output : `${data.report.period.selector}.${format}`;
|
|
314
|
+
const file = await api.downloadFinanceReport(client, id, { format });
|
|
315
|
+
const path = writeDownload(output, Buffer.from(file.bytes), { overwrite: flags.overwrite === true });
|
|
316
|
+
emit({ report: data.report, output: path, bytes: file.bytes.length, sha256: file.sha256 }, `Wrote ${path} (${file.bytes.length} bytes)`);
|
|
317
|
+
return EXIT.OK;
|
|
318
|
+
}
|
|
319
|
+
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances reports [list|get <id>|download <id>|delete <id>]');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
case 'statements': {
|
|
323
|
+
const action = args[0];
|
|
324
|
+
if (action === 'import') {
|
|
325
|
+
const path = args[1];
|
|
326
|
+
if (!path) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements import <file.pdf> --account <id> --period 2026-08 (or --from/--to) [--timezone …]');
|
|
327
|
+
const accountId = typeof flags.account === 'string' ? flags.account : null;
|
|
328
|
+
if (!accountId) throw new CliExit(EXIT.INVALID, '--account <account-id> is required');
|
|
329
|
+
const sel = periodSelection(flags);
|
|
330
|
+
let bytes;
|
|
331
|
+
try {
|
|
332
|
+
bytes = readFileSync(path);
|
|
333
|
+
} catch (e) {
|
|
334
|
+
throw new CliExit(EXIT.INVALID, `Could not read ${path}: ${e.message}`);
|
|
335
|
+
}
|
|
336
|
+
const data = await api.importFinanceStatement(client, {
|
|
337
|
+
file: bytes,
|
|
338
|
+
filename: basename(path),
|
|
339
|
+
accountId,
|
|
340
|
+
...sel,
|
|
341
|
+
institutionLabel: typeof flags.institution === 'string' ? flags.institution : undefined,
|
|
342
|
+
cycle: typeof flags.cycle === 'string' ? flags.cycle : undefined,
|
|
343
|
+
notes: typeof flags.notes === 'string' ? flags.notes : undefined,
|
|
344
|
+
});
|
|
345
|
+
emit(data, `${data.duplicateOf === data.statement.id ? 'Already imported' : 'Imported'} statement ${data.statement.id} for ${data.statement.periodStart}..${data.statement.periodEnd}. ${data.note}`);
|
|
346
|
+
return EXIT.OK;
|
|
347
|
+
}
|
|
348
|
+
if (action === 'list') {
|
|
349
|
+
const sel = flags.period || (flags.from && flags.to) ? periodSelection(flags) : {};
|
|
350
|
+
const statements = await api.listFinanceStatements(client, { ...sel, accountId: typeof flags.account === 'string' ? flags.account : undefined, limit: flags.limit ? Number(flags.limit) : undefined });
|
|
351
|
+
emit(
|
|
352
|
+
{ statements },
|
|
353
|
+
statements.length
|
|
354
|
+
? statements.map((s) => `${s.id} ${s.periodStart}..${s.periodEnd} ${s.institutionLabel || ''} ${s.bytes} bytes ${s.reconciliation ? s.reconciliation.state : 'not reconciled'} (user supplied)`).join('\n')
|
|
355
|
+
: 'No statements imported.',
|
|
356
|
+
);
|
|
357
|
+
return EXIT.OK;
|
|
358
|
+
}
|
|
359
|
+
if (action === 'get') {
|
|
360
|
+
const id = args[1];
|
|
361
|
+
if (!id) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements get <statement-id>');
|
|
362
|
+
const data = await api.getFinanceStatement(client, id);
|
|
363
|
+
emit(data, `${data.statement.id} ${data.statement.periodStart}..${data.statement.periodEnd} ${data.statement.label} ${data.statement.reconciliation ? data.statement.reconciliation.state : 'not reconciled'}`);
|
|
364
|
+
return EXIT.OK;
|
|
365
|
+
}
|
|
366
|
+
if (action === 'download') {
|
|
367
|
+
const id = args[1];
|
|
368
|
+
if (!id) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements download <statement-id> --output <file.pdf>');
|
|
369
|
+
const file = await api.downloadFinanceStatement(client, id);
|
|
370
|
+
const output = typeof flags.output === 'string' ? flags.output : file.filename || `${id}.pdf`;
|
|
371
|
+
const path = writeDownload(output, Buffer.from(file.bytes), { overwrite: flags.overwrite === true });
|
|
372
|
+
emit({ output: path, bytes: file.bytes.length, sha256: file.sha256 }, `Wrote ${path} (${file.bytes.length} bytes; original bytes, user supplied)`);
|
|
373
|
+
return EXIT.OK;
|
|
374
|
+
}
|
|
375
|
+
if (action === 'reconcile') {
|
|
376
|
+
const id = args[1];
|
|
377
|
+
if (!id) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements reconcile <statement-id> --report <report-id> --opening <n> --closing <n> --currency USD');
|
|
378
|
+
const reportId = typeof flags.report === 'string' ? flags.report : null;
|
|
379
|
+
if (!reportId) throw new CliExit(EXIT.INVALID, '--report <report-id> is required');
|
|
380
|
+
for (const name of ['opening', 'closing', 'currency']) {
|
|
381
|
+
if (typeof flags[name] !== 'string' && typeof flags[name] !== 'number') throw new CliExit(EXIT.INVALID, `--${name} is required`);
|
|
382
|
+
}
|
|
383
|
+
const rec = await api.reconcileFinanceStatement(client, id, {
|
|
384
|
+
reportId,
|
|
385
|
+
opening: String(flags.opening),
|
|
386
|
+
closing: String(flags.closing),
|
|
387
|
+
credits: flags.credits !== undefined ? String(flags.credits) : undefined,
|
|
388
|
+
debits: flags.debits !== undefined ? String(flags.debits) : undefined,
|
|
389
|
+
currency: String(flags.currency),
|
|
390
|
+
signConvention: flags['liability-positive'] ? 'liability_positive' : 'as_stated',
|
|
391
|
+
acknowledge: flags.acknowledge === true,
|
|
392
|
+
});
|
|
393
|
+
emit({ reconciliation: rec }, `${rec.state}: expected closing ${rec.expectedClosing}, entered ${rec.normalized.closing}, difference ${rec.difference} ${rec.currency}`);
|
|
394
|
+
return rec.state === 'mismatch' ? EXIT.STRICT : EXIT.OK;
|
|
395
|
+
}
|
|
396
|
+
if (action === 'delete') {
|
|
397
|
+
const id = args[1];
|
|
398
|
+
if (!id) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements delete <statement-id> --yes');
|
|
399
|
+
if (!flags.yes) throw new CliExit(EXIT.INVALID, 'Deleting removes the document, not bank activity. Re-run with --yes.');
|
|
400
|
+
const data = await api.deleteFinanceStatement(client, id);
|
|
401
|
+
emit(data, data.note);
|
|
402
|
+
return EXIT.OK;
|
|
403
|
+
}
|
|
404
|
+
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements [import <file>|list|get <id>|download <id>|reconcile <id>|delete <id>]');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
default:
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
} catch (err) {
|
|
411
|
+
if (err instanceof CliExit) {
|
|
412
|
+
if (err.message) errOut(err.message);
|
|
413
|
+
return err.exitCode;
|
|
414
|
+
}
|
|
415
|
+
const code = exitCodeFor(err);
|
|
416
|
+
const detail = err && err.code ? ` (${err.code})` : '';
|
|
417
|
+
errOut(`${err && err.message ? err.message : String(err)}${detail}`);
|
|
418
|
+
if (json) out(JSON.stringify({ error: { code: err && err.code ? err.code : 'error', message: err && err.message ? err.message : String(err) } }));
|
|
419
|
+
return code;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export const EXTENDED_SUBCOMMANDS = ['connect', 'disconnect', 'consent', 'backfill', 'jobs', 'coverage', 'report', 'reports', 'statements'];
|