@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
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ All notable changes to `@profullstack/coinpay` will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.10.0] - 2026-09-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `coinpay finances report`: monthly, quarterly and custom-period activity
|
|
12
|
+
reports generated server-side from an immutable snapshot, downloadable as
|
|
13
|
+
PDF, HTML, CSV or JSON (`--period 2026-08`, `--period 2026-Q2`, or
|
|
14
|
+
`--from/--to` with an exclusive end; `--account`, `--scope`, `--timezone`,
|
|
15
|
+
`--format`, `--output`, `--strict`, `--wait`, `--json`). Every report
|
|
16
|
+
states that it was generated by CoinPay from imported account data and is
|
|
17
|
+
not an institution-issued statement, and carries `local_export_complete`,
|
|
18
|
+
`provider_coverage` and `reconciliation_status`.
|
|
19
|
+
- `coinpay finances backfill`, `jobs`, `coverage`: durable, resumable
|
|
20
|
+
calendar-period fetches in provider-safe chunks, with per-account coverage
|
|
21
|
+
derived from what was actually requested.
|
|
22
|
+
- `coinpay finances statements import|list|get|download|reconcile|delete`:
|
|
23
|
+
keep original bank statements (PDF, user supplied, never parsed) beside an
|
|
24
|
+
account and period, and check their balances against a report revision
|
|
25
|
+
with exact decimal arithmetic.
|
|
26
|
+
- `coinpay finances connect` (setup token prompted without echo or via
|
|
27
|
+
`--setup-token-stdin`), `disconnect`, `consent <id> on|off`.
|
|
28
|
+
- SDK: `finances-reports` module (`createFinanceReport`, `waitForFinanceReport`,
|
|
29
|
+
`downloadFinanceReport`, `createBackfillJob`, `getFinanceCoverage`,
|
|
30
|
+
`importFinanceStatement`, `reconcileFinanceStatement`, ...), and
|
|
31
|
+
`CoinPayClient.requestBinary()` / `requestForm()` for downloads and uploads.
|
|
32
|
+
- Exit codes for the new subcommands: 0 done or job accepted, 2 invalid
|
|
33
|
+
input, 3 strict/completeness rejection, 4 authentication, 5 provider or
|
|
34
|
+
storage failure. In `--json` mode stdout carries only the result.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
- `listAllFinanceTransactions` now forwards `endDate`.
|
|
38
|
+
|
|
8
39
|
## [0.9.0] - 2026-09-05
|
|
9
40
|
|
|
10
41
|
### Added
|
|
@@ -31,6 +62,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
31
62
|
Built from about six months of history rather than the dashboard window,
|
|
32
63
|
because a monthly charge is invisible in thirty days of rows, and averaged
|
|
33
64
|
over the history that exists rather than the history requested.
|
|
65
|
+
- `coinpay finances scope <account-id> <business|personal|clear>`, and a `Side`
|
|
66
|
+
column on the accounts table. Which set of books an account belongs to was a
|
|
67
|
+
guess from its name, which reads "Business Checking" correctly and is blind to
|
|
68
|
+
a personal card carrying company spend. The correction is now stored in
|
|
69
|
+
`finance_accounts.scope_override`, wins over the guess, and survives a re-sync
|
|
70
|
+
— the same contract as `kind_override`. An overridden account is marked with a
|
|
71
|
+
trailing `*`, so a wrong split is traceable to a guess rather than a decision.
|
|
34
72
|
- Plain-text `coinpay finances summary|accounts|ledger|connections|sync`
|
|
35
73
|
(`--json` on all of them) for scripts and for Node older than 22.6.
|
|
36
74
|
- SDK `finances` module, also importable as `@profullstack/coinpay/finances`:
|
package/README.md
CHANGED
|
@@ -697,6 +697,7 @@ coinpay finances # live dashboard (needs `coinpay login`)
|
|
|
697
697
|
coinpay finances --days 90 # window for earnings / cashflow: 7, 30, 90, 365
|
|
698
698
|
coinpay finances summary # plain text, --json for machines
|
|
699
699
|
coinpay finances position # debt vs income, credits vs debits (also: debt)
|
|
700
|
+
coinpay finances scope <id> business # put an account on the company's books
|
|
700
701
|
coinpay finances accounts # linked bank & credit-card accounts (SimpleFIN / Plaid)
|
|
701
702
|
coinpay finances ledger --search anthropic --limit 20
|
|
702
703
|
coinpay finances connections # institutions and their last sync
|
|
@@ -718,6 +719,13 @@ instalment, months to clear each balance at its current payment rate, debt-to-in
|
|
|
718
719
|
debt-service ratio, months of cover, card utilisation, the recurring bills it found with
|
|
719
720
|
their next due date, and a business-versus-personal split of all of it.
|
|
720
721
|
|
|
722
|
+
The business/personal split starts as a guess from the account name, which gets
|
|
723
|
+
"Business Checking (4672)" right and cannot see a personal card carrying company
|
|
724
|
+
spend. Correct it with `coinpay finances scope <account-id> business|personal`,
|
|
725
|
+
or `clear` to hand it back to the guess. The correction is stored separately from
|
|
726
|
+
the derived value, so a re-sync cannot clobber it, and corrected accounts carry a
|
|
727
|
+
trailing `*` in the `Side` column.
|
|
728
|
+
|
|
721
729
|
Two things about those numbers. Transfers and card payments are excluded from both
|
|
722
730
|
income and spending — a feed holding both a checking account and the card it pays
|
|
723
731
|
contains every card payment twice, so counting raw credits as income inflates both
|
package/bin/coinpay.js
CHANGED
|
@@ -161,6 +161,14 @@ function createUnauthenticatedClient() {
|
|
|
161
161
|
|
|
162
162
|
const BOOLEAN_FLAGS = new Set([
|
|
163
163
|
'active',
|
|
164
|
+
// finances reports / statements
|
|
165
|
+
'wait',
|
|
166
|
+
'strict',
|
|
167
|
+
'setup-token-stdin',
|
|
168
|
+
'overwrite',
|
|
169
|
+
'no-pending',
|
|
170
|
+
'liability-positive',
|
|
171
|
+
'acknowledge',
|
|
164
172
|
'plain',
|
|
165
173
|
'no-stream',
|
|
166
174
|
'hidden',
|
|
@@ -450,10 +458,26 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
450
458
|
summary Plain-text headline numbers (--json for machines)
|
|
451
459
|
position Debt vs income, credits vs debits, recurring bills
|
|
452
460
|
(also: coinpay finances debt)
|
|
461
|
+
scope <id> <side> Put an account on the business or personal books
|
|
462
|
+
(side: business | personal | clear)
|
|
453
463
|
accounts Linked bank and card accounts with balances
|
|
454
464
|
ledger Bank/card transactions (--limit, --search, --category, --account)
|
|
455
465
|
connections Linked institutions and their last sync
|
|
456
466
|
sync Pull fresh balances from the bank bridge (--days)
|
|
467
|
+
connect Link SimpleFIN: token prompted without echo, or --setup-token-stdin
|
|
468
|
+
disconnect <id> Stop syncs and drop the credential; history stays (--yes)
|
|
469
|
+
consent <id> on|off Opt a connection into a once-daily background sync
|
|
470
|
+
backfill Fetch a calendar period in safe chunks (--period 2026-Q2 |
|
|
471
|
+
--from/--to, --connection, --timezone, --wait, --json)
|
|
472
|
+
jobs [get|cancel <id>] Persisted sync/report jobs; reading one costs no request
|
|
473
|
+
coverage What part of a period was ever fetched (--period, --account)
|
|
474
|
+
report Monthly/quarterly/custom activity report (--period 2026-08 |
|
|
475
|
+
--from/--to; --account, --scope, --timezone, --format pdf|csv|html|json,
|
|
476
|
+
--output <file>, --strict, --wait, --json)
|
|
477
|
+
reports [get|download|delete <id>] Report history and downloads
|
|
478
|
+
statements import <pdf> Keep an original bank statement (--account, --period|--from/--to)
|
|
479
|
+
statements list|get|download|reconcile|delete
|
|
480
|
+
Statement library; reconcile takes --report --opening --closing --currency
|
|
457
481
|
|
|
458
482
|
${colors.bright}escrow${colors.reset}
|
|
459
483
|
create Create a new escrow
|
|
@@ -3486,7 +3510,7 @@ const MENU_COMMANDS = [
|
|
|
3486
3510
|
const SUBCOMMANDS = {
|
|
3487
3511
|
config: [['set-key', 'Set your API key'], ['set-url', 'Set custom API URL'], ['show', 'Show current configuration']],
|
|
3488
3512
|
auth: [['register', 'Register new merchant account'], ['login', 'Login to merchant account'], ['me', 'Show current merchant info']],
|
|
3489
|
-
finances: [['tui', 'Live dashboard (default)'], ['summary', 'Headline numbers as text'], ['position', 'Debt vs income, credits vs debits'], ['accounts', 'Bank & card accounts'], ['ledger', 'Bank/card transactions'], ['connections', 'Linked institutions'], ['sync', 'Pull fresh bank data']],
|
|
3513
|
+
finances: [['tui', 'Live dashboard (default)'], ['summary', 'Headline numbers as text'], ['position', 'Debt vs income, credits vs debits'], ['scope', 'Set an account business or personal'], ['accounts', 'Bank & card accounts'], ['ledger', 'Bank/card transactions'], ['connections', 'Linked institutions'], ['sync', 'Pull fresh bank data'], ['report', 'Monthly/quarterly activity report'], ['backfill', 'Fetch a calendar period'], ['statements', 'Original bank statements'], ['jobs', 'Sync and report jobs']],
|
|
3490
3514
|
payment: [['create', 'Create a new payment'], ['get', 'Get payment details <id>'], ['list', 'List payments'], ['qr', 'Get payment QR code <id>']],
|
|
3491
3515
|
invoice: [['create', 'Create a draft invoice'], ['list', 'List invoices'], ['get', 'Get invoice details <id>'], ['update', 'Update a draft invoice <id>'], ['publish', 'Publish an invoice <id>'], ['send', 'Send an invoice <id>'], ['delete', 'Delete a draft invoice <id>']],
|
|
3492
3516
|
tokens: [['list', 'List checkout tokens']],
|
|
@@ -4023,6 +4047,58 @@ async function handleFinances(subcommand, args, flags) {
|
|
|
4023
4047
|
case 'dashboard':
|
|
4024
4048
|
case 'watch': {
|
|
4025
4049
|
const { client, token, baseUrl } = financesClient();
|
|
4050
|
+
|
|
4051
|
+
// A document for somebody who bills by the hour. Every question it
|
|
4052
|
+
// answers up front is a question nobody pays to have asked, so it
|
|
4053
|
+
// defaults to the whole transaction list and a year of history.
|
|
4054
|
+
if (flags.pdf || flags.report) {
|
|
4055
|
+
const reportDays = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : 365;
|
|
4056
|
+
const limit = Number.parseInt(String(flags.limit ?? ''), 10);
|
|
4057
|
+
const snapshot = await fin.collectFinanceSnapshot(client, {
|
|
4058
|
+
days: reportDays,
|
|
4059
|
+
limit: Number.isFinite(limit) && limit > 0 ? limit : 5000,
|
|
4060
|
+
businessId: flags['business-id'],
|
|
4061
|
+
// Page the whole window. Asking for 5000 returns 500, and a report
|
|
4062
|
+
// built on one page understates the period without saying so.
|
|
4063
|
+
allTransactions: true,
|
|
4064
|
+
});
|
|
4065
|
+
const { buildFinanceReportHtml } = await import('../src/finances-report.js');
|
|
4066
|
+
const html = buildFinanceReportHtml(snapshot, {
|
|
4067
|
+
preparedFor: typeof flags.for === 'string' ? flags.for : '',
|
|
4068
|
+
preparedBy: typeof flags.by === 'string' ? flags.by : '',
|
|
4069
|
+
entity: typeof flags.entity === 'string' ? flags.entity : '',
|
|
4070
|
+
includeTransactions: !flags['no-transactions'],
|
|
4071
|
+
transactionLimit: 0,
|
|
4072
|
+
title: typeof flags.title === 'string' ? flags.title : 'Financial summary',
|
|
4073
|
+
});
|
|
4074
|
+
|
|
4075
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
4076
|
+
const base = typeof flags.pdf === 'string' && flags.pdf
|
|
4077
|
+
? String(flags.pdf).replace(/\.pdf$/i, '')
|
|
4078
|
+
: `finances-${stamp}`;
|
|
4079
|
+
const pdfPath = `${base}.pdf`;
|
|
4080
|
+
const htmlOut = `${base}.html`;
|
|
4081
|
+
|
|
4082
|
+
const { writeFileSync } = await import('node:fs');
|
|
4083
|
+
writeFileSync(htmlOut, html, 'utf8');
|
|
4084
|
+
|
|
4085
|
+
const { htmlToPdf, installHint } = await import('../src/pdf.js');
|
|
4086
|
+
const result = await htmlToPdf(html, pdfPath);
|
|
4087
|
+
if (result.ok) {
|
|
4088
|
+
console.log(`Wrote ${pdfPath}`);
|
|
4089
|
+
console.log(` ${htmlOut}`);
|
|
4090
|
+
} else {
|
|
4091
|
+
// The HTML is the report. Losing the PDF is a downgrade, not a failure.
|
|
4092
|
+
console.log(`Wrote ${htmlOut}`);
|
|
4093
|
+
console.log('');
|
|
4094
|
+
console.log('Could not render a PDF on this machine.');
|
|
4095
|
+
console.log(installHint(result.tried));
|
|
4096
|
+
console.log('');
|
|
4097
|
+
console.log('Or open the HTML in a browser and print it to PDF.');
|
|
4098
|
+
}
|
|
4099
|
+
return;
|
|
4100
|
+
}
|
|
4101
|
+
|
|
4026
4102
|
const wantsText = flags.plain || flags.json || !process.stdout.isTTY || !process.stdin.isTTY;
|
|
4027
4103
|
if (wantsText) {
|
|
4028
4104
|
const snapshot = await fin.collectFinanceSnapshot(client, { days, businessId: flags['business-id'] });
|
|
@@ -4073,6 +4149,30 @@ async function handleFinances(subcommand, args, flags) {
|
|
|
4073
4149
|
return;
|
|
4074
4150
|
}
|
|
4075
4151
|
|
|
4152
|
+
case 'scope': {
|
|
4153
|
+
// Which set of books an account belongs to. The name-derived guess is
|
|
4154
|
+
// right about "Business Checking" and blind to a personal card carrying
|
|
4155
|
+
// company spend, so the correction is stored and always wins.
|
|
4156
|
+
const { client } = financesClient();
|
|
4157
|
+
const accountId = args[0];
|
|
4158
|
+
const value = (args[1] || '').toLowerCase();
|
|
4159
|
+
if (!accountId) {
|
|
4160
|
+
print.error('Usage: coinpay finances scope <account-id> <business|personal|clear> (ids: coinpay finances accounts)');
|
|
4161
|
+
process.exit(1);
|
|
4162
|
+
}
|
|
4163
|
+
const scope = value === 'clear' || value === 'auto' || value === '' ? null : value;
|
|
4164
|
+
if (scope !== null && scope !== 'business' && scope !== 'personal') {
|
|
4165
|
+
print.error(`Unknown scope '${value}'. Use business, personal, or clear.`);
|
|
4166
|
+
process.exit(1);
|
|
4167
|
+
}
|
|
4168
|
+
const account = await fin.updateFinanceAccount(client, accountId, { scope });
|
|
4169
|
+
if (flags.json) { print.json({ account }); return; }
|
|
4170
|
+
print.success(
|
|
4171
|
+
`${account.name} → ${account.effective_scope}${scope === null ? ' (derived from the name again)' : ''}`,
|
|
4172
|
+
);
|
|
4173
|
+
return;
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4076
4176
|
case 'accounts': {
|
|
4077
4177
|
const { client } = financesClient();
|
|
4078
4178
|
const accounts = await fin.listFinanceAccounts(client, { includeHidden: Boolean(flags.hidden) });
|
|
@@ -4084,6 +4184,7 @@ async function handleFinances(subcommand, args, flags) {
|
|
|
4084
4184
|
{ title: 'Institution', render: (a) => a.org_name || '—' },
|
|
4085
4185
|
{ title: 'Account', render: (a) => a.name },
|
|
4086
4186
|
{ title: 'Kind', render: (a) => a.effective_kind || a.kind },
|
|
4187
|
+
{ title: 'Side', render: (a) => `${a.effective_scope || '—'}${a.scope_override ? '*' : ''}` },
|
|
4087
4188
|
{ title: 'Balance', align: 'right', render: (a) => money(a.display_balance ?? a.balance ?? 0, a.currency || 'USD') },
|
|
4088
4189
|
{ title: 'Available', align: 'right', render: (a) => (a.available_balance == null ? '—' : money(a.available_balance, a.currency || 'USD')) },
|
|
4089
4190
|
{ title: 'As of', render: (a) => shortDate(a.balance_date) },
|
|
@@ -4162,9 +4263,29 @@ async function handleFinances(subcommand, args, flags) {
|
|
|
4162
4263
|
return;
|
|
4163
4264
|
}
|
|
4164
4265
|
|
|
4266
|
+
case 'connect':
|
|
4267
|
+
case 'disconnect':
|
|
4268
|
+
case 'consent':
|
|
4269
|
+
case 'backfill':
|
|
4270
|
+
case 'jobs':
|
|
4271
|
+
case 'coverage':
|
|
4272
|
+
case 'report':
|
|
4273
|
+
case 'reports':
|
|
4274
|
+
case 'statements': {
|
|
4275
|
+
const { client } = financesClient();
|
|
4276
|
+
const { runFinancesCommand } = await import('../src/finances-commands.js');
|
|
4277
|
+
const code = await runFinancesCommand(subcommand, args, flags, {
|
|
4278
|
+
client,
|
|
4279
|
+
out: (line) => console.log(line),
|
|
4280
|
+
err: (line) => console.error(line),
|
|
4281
|
+
});
|
|
4282
|
+
if (code !== 0) process.exit(code);
|
|
4283
|
+
return;
|
|
4284
|
+
}
|
|
4285
|
+
|
|
4165
4286
|
default:
|
|
4166
4287
|
print.error(`Unknown finances command: ${subcommand}`);
|
|
4167
|
-
console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync]');
|
|
4288
|
+
console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements]');
|
|
4168
4289
|
process.exit(1);
|
|
4169
4290
|
}
|
|
4170
4291
|
}
|
|
@@ -4508,12 +4629,16 @@ async function handleOAuth(subcommand, args, flags) {
|
|
|
4508
4629
|
description,
|
|
4509
4630
|
});
|
|
4510
4631
|
|
|
4632
|
+
// The API wraps the record: { success, client: { ..., client_secret }, warning }.
|
|
4633
|
+
// Accept a bare record too so either shape prints.
|
|
4634
|
+
const created = result.client ?? result;
|
|
4635
|
+
|
|
4511
4636
|
print.success('OAuth client created');
|
|
4512
|
-
if (
|
|
4513
|
-
console.log(`\n ${colors.bright}Client ID:${colors.reset} ${
|
|
4637
|
+
if (created.client_id) {
|
|
4638
|
+
console.log(`\n ${colors.bright}Client ID:${colors.reset} ${created.client_id}`);
|
|
4514
4639
|
}
|
|
4515
|
-
if (
|
|
4516
|
-
console.log(` ${colors.bright}Client Secret:${colors.reset} ${colors.yellow}${
|
|
4640
|
+
if (created.client_secret) {
|
|
4641
|
+
console.log(` ${colors.bright}Client Secret:${colors.reset} ${colors.yellow}${created.client_secret}${colors.reset}`);
|
|
4517
4642
|
console.log();
|
|
4518
4643
|
print.warn('Save the client secret — it is only shown once!');
|
|
4519
4644
|
}
|
|
@@ -4531,13 +4656,15 @@ async function handleOAuth(subcommand, args, flags) {
|
|
|
4531
4656
|
}
|
|
4532
4657
|
|
|
4533
4658
|
const result = await client.getOAuthClient(id);
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
if (
|
|
4539
|
-
if (
|
|
4540
|
-
if (
|
|
4659
|
+
// GET /oauth/clients/:id also wraps the record as { success, client }.
|
|
4660
|
+
const found = result.client ?? result;
|
|
4661
|
+
|
|
4662
|
+
print.success(`OAuth Client: ${found.name || id}`);
|
|
4663
|
+
if (found.client_id) print.info(` Client ID: ${found.client_id}`);
|
|
4664
|
+
if (found.description) print.info(` Description: ${found.description}`);
|
|
4665
|
+
if (found.redirect_uris) print.info(` Redirect URIs: ${found.redirect_uris.join(', ')}`);
|
|
4666
|
+
if (found.scopes) print.info(` Scopes: ${Array.isArray(found.scopes) ? found.scopes.join(', ') : found.scopes}`);
|
|
4667
|
+
if (found.created_at) print.info(` Created: ${found.created_at}`);
|
|
4541
4668
|
|
|
4542
4669
|
if (flags.json) print.json(result);
|
|
4543
4670
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/coinpay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "CoinPay SDK & CLI \u2014 Accept cryptocurrency and card payments (BTC, ETH, SOL, POL, BCH, USDC) with wallet, swap and a live finances dashboard",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -58,6 +58,19 @@
|
|
|
58
58
|
},
|
|
59
59
|
"./finances-tui": {
|
|
60
60
|
"import": "./src/finances-tui.js"
|
|
61
|
+
},
|
|
62
|
+
"./finances-reports": {
|
|
63
|
+
"types": "./src/finances-reports.d.ts",
|
|
64
|
+
"import": "./src/finances-reports.js",
|
|
65
|
+
"default": "./src/finances-reports.js"
|
|
66
|
+
},
|
|
67
|
+
"./finances-commands": {
|
|
68
|
+
"import": "./src/finances-commands.js",
|
|
69
|
+
"default": "./src/finances-commands.js"
|
|
70
|
+
},
|
|
71
|
+
"./finances-report": {
|
|
72
|
+
"import": "./src/finances-report.js",
|
|
73
|
+
"default": "./src/finances-report.js"
|
|
61
74
|
}
|
|
62
75
|
},
|
|
63
76
|
"files": [
|
|
@@ -120,7 +133,7 @@
|
|
|
120
133
|
"devDependencies": {
|
|
121
134
|
"eslint": "^8.57.0",
|
|
122
135
|
"prettier": "^3.4.1",
|
|
123
|
-
"vitest": "^4.1.
|
|
136
|
+
"vitest": "^4.1.11"
|
|
124
137
|
},
|
|
125
138
|
"dependencies": {
|
|
126
139
|
"@inquirer/prompts": "^8.3.0",
|
package/src/client.d.ts
CHANGED
|
@@ -313,6 +313,16 @@ export class CoinPayClient {
|
|
|
313
313
|
* @param eventType - Event type to simulate (default: `'payment.completed'`)
|
|
314
314
|
*/
|
|
315
315
|
testWebhook(businessId: string, eventType?: string): Promise<Record<string, unknown>>;
|
|
316
|
+
/** Fetch a binary body (PDF, CSV) with its describing headers. */
|
|
317
|
+
requestBinary(endpoint: string, options?: RequestInit): Promise<{
|
|
318
|
+
bytes: Uint8Array;
|
|
319
|
+
contentType: string;
|
|
320
|
+
filename: string | null;
|
|
321
|
+
sha256: string | null;
|
|
322
|
+
headers: Record<string, string>;
|
|
323
|
+
}>;
|
|
324
|
+
/** POST a multipart form (file upload). */
|
|
325
|
+
requestForm(endpoint: string, form: FormData): Promise<any>;
|
|
316
326
|
}
|
|
317
327
|
|
|
318
328
|
export default CoinPayClient;
|
package/src/client.js
CHANGED
|
@@ -77,6 +77,97 @@ export class CoinPayClient {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Fetch a binary body (a PDF, a CSV) rather than JSON.
|
|
82
|
+
*
|
|
83
|
+
* `request()` always parses JSON, which a file download is not. This
|
|
84
|
+
* returns the bytes plus the headers that describe them; an error body
|
|
85
|
+
* is still parsed as JSON so its `code` survives.
|
|
86
|
+
* @param {string} endpoint - API endpoint
|
|
87
|
+
* @param {Object} [options] - Fetch options
|
|
88
|
+
* @returns {Promise<{bytes: Uint8Array, contentType: string, filename: string|null, sha256: string|null, headers: Record<string,string>}>}
|
|
89
|
+
*/
|
|
90
|
+
async requestBinary(endpoint, options = {}) {
|
|
91
|
+
if (!this.#apiKey) {
|
|
92
|
+
throw new Error('API key is required for authenticated requests.');
|
|
93
|
+
}
|
|
94
|
+
const url = `${this.#baseUrl}${endpoint}`;
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const timeoutId = setTimeout(() => controller.abort(), this.#timeout);
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetch(url, {
|
|
99
|
+
...options,
|
|
100
|
+
signal: controller.signal,
|
|
101
|
+
headers: { 'Authorization': `Bearer ${this.#apiKey}`, ...options.headers },
|
|
102
|
+
});
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
let data = {};
|
|
105
|
+
try { data = await response.json(); } catch { /* not JSON */ }
|
|
106
|
+
const structured = data && data.error && typeof data.error === 'object' ? data.error : null;
|
|
107
|
+
const error = new Error((structured && structured.message) || data.error || `HTTP ${response.status}`);
|
|
108
|
+
error.status = response.status;
|
|
109
|
+
error.response = data;
|
|
110
|
+
if (structured && structured.code) error.code = structured.code;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
const headers = {};
|
|
114
|
+
response.headers.forEach((value, key) => { headers[key] = value; });
|
|
115
|
+
const disposition = headers['content-disposition'] || '';
|
|
116
|
+
const match = /filename="?([^";]+)"?/i.exec(disposition);
|
|
117
|
+
return {
|
|
118
|
+
bytes: new Uint8Array(await response.arrayBuffer()),
|
|
119
|
+
contentType: headers['content-type'] || 'application/octet-stream',
|
|
120
|
+
filename: match ? match[1] : null,
|
|
121
|
+
sha256: headers['x-content-sha256'] || null,
|
|
122
|
+
headers,
|
|
123
|
+
};
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error.name === 'AbortError') throw new Error(`Request timeout after ${this.#timeout}ms`);
|
|
126
|
+
throw error;
|
|
127
|
+
} finally {
|
|
128
|
+
clearTimeout(timeoutId);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* POST a multipart form (a file upload). The browser/undici sets the
|
|
134
|
+
* boundary, so no Content-Type is forced here.
|
|
135
|
+
* @param {string} endpoint - API endpoint
|
|
136
|
+
* @param {FormData} form - The form to send
|
|
137
|
+
* @returns {Promise<Object>} API response
|
|
138
|
+
*/
|
|
139
|
+
async requestForm(endpoint, form) {
|
|
140
|
+
if (!this.#apiKey) {
|
|
141
|
+
throw new Error('API key is required for authenticated requests.');
|
|
142
|
+
}
|
|
143
|
+
const url = `${this.#baseUrl}${endpoint}`;
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
const timeoutId = setTimeout(() => controller.abort(), this.#timeout);
|
|
146
|
+
try {
|
|
147
|
+
const response = await fetch(url, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
body: form,
|
|
150
|
+
signal: controller.signal,
|
|
151
|
+
headers: { 'Authorization': `Bearer ${this.#apiKey}` },
|
|
152
|
+
});
|
|
153
|
+
const data = await response.json();
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
const structured = data && data.error && typeof data.error === 'object' ? data.error : null;
|
|
156
|
+
const error = new Error((structured && structured.message) || data.error || `HTTP ${response.status}`);
|
|
157
|
+
error.status = response.status;
|
|
158
|
+
error.response = data;
|
|
159
|
+
if (structured && structured.code) error.code = structured.code;
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
return data;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error.name === 'AbortError') throw new Error(`Request timeout after ${this.#timeout}ms`);
|
|
165
|
+
throw error;
|
|
166
|
+
} finally {
|
|
167
|
+
clearTimeout(timeoutId);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
80
171
|
/**
|
|
81
172
|
* Make an unauthenticated API request (for registration/login)
|
|
82
173
|
* @param {string} endpoint - API endpoint
|