@profullstack/coinpay 0.21.0 → 0.23.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 CHANGED
@@ -5,6 +5,26 @@ 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.23.0] - 2026-10-21
9
+
10
+ ### Added
11
+ - `createPayment({ successUrl, cancelUrl })`, like Stripe Checkout: once the payment confirms, the
12
+ hosted pay page sends the customer to `successUrl` (`{PAYMENT_ID}` is replaced with the payment id);
13
+ `cancelUrl` is the "Back to merchant" link from an expired checkout. Before this the crypto checkout
14
+ had no way to return the buyer and left them on "Payment Complete!".
15
+
16
+ ## [0.22.0] - 2026-10-19
17
+
18
+ ### Added
19
+ - `coinpay document publish <dir> --business-id <id>`: encrypt and publish a whole directory, keeping
20
+ its structure (under a folder named like the directory, or `--folder dest`, `--folder /` for the top
21
+ level). Publishing again syncs: new files upload, changed files become a new version, unchanged files
22
+ are skipped, signed documents are left alone, nothing remote is deleted. `--dry-run` shows the plan;
23
+ hidden files and `node_modules` are skipped unless `--include-hidden`.
24
+ - `coinpay document move <id> <folder>`; `upload --folder a/b`; `list` shows folders.
25
+ - Any file type can be uploaded (unknown types are opaque downloads; the server only holds ciphertext).
26
+ - SDK: `publishDirectory`, `planPublish`, `moveDocument`; `uploadDocument({ folder })`.
27
+
8
28
  ## [0.21.0] - 2026-10-19
9
29
 
10
30
  ### Added
package/bin/coinpay.js CHANGED
@@ -80,7 +80,9 @@ import {
80
80
  downloadDocument,
81
81
  listDocumentVersions,
82
82
  uploadDocumentVersion,
83
+ moveDocument,
83
84
  } from '../src/documents.js';
85
+ import { publishDirectory } from '../src/documents-publish.js';
84
86
  import { readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from 'fs';
85
87
  import { execSync, spawn } from 'child_process';
86
88
  import { createInterface } from 'readline';
@@ -552,7 +554,11 @@ ${colors.cyan}Commands:${colors.reset}
552
554
  ${colors.bright}document${colors.reset} (end-to-end encrypted: CoinPay never sees the contents)
553
555
  vault-setup Create the documents passphrase (--business-id, admins)
554
556
  list Documents (--business-id) with signing progress
555
- upload <file> Encrypt and upload: --business-id [--title] [--message]
557
+ upload <file> Encrypt and upload: --business-id [--folder a/b] [--title] [--message]
558
+ publish <dir> Sync a whole directory, keeping its structure: --business-id
559
+ [--folder dest | --folder / for top level] [--dry-run] [--include-hidden]
560
+ new files upload, changed ones become versions, signed ones are left alone
561
+ move <id> <folder> File a document elsewhere (/ = top level)
556
562
  share <id> --signers "Ada <ada@x.com>, bob@y.com" [--editors "..."] [--viewers "..."]
557
563
  [--message] [--no-email] [--email-key puts the key in the email]
558
564
  Prints each full link (with #k= key): send it privately
@@ -1328,7 +1334,7 @@ async function documentsVault(client, businessId) {
1328
1334
 
1329
1335
  async function handleDocument(subcommand, args, flags) {
1330
1336
  const client = createClient();
1331
- requireFlagValues(flags, ['business-id', 'title', 'message', 'signers', 'viewers', 'editors', 'reason', 'out', 'note', 'revision']);
1337
+ requireFlagValues(flags, ['business-id', 'title', 'message', 'signers', 'viewers', 'editors', 'reason', 'out', 'note', 'revision', 'folder']);
1332
1338
  switch (subcommand) {
1333
1339
  case 'vault-setup': {
1334
1340
  if (!flags['business-id']) usageExit('coinpay document vault-setup --business-id <id>');
@@ -1344,12 +1350,56 @@ async function handleDocument(subcommand, args, flags) {
1344
1350
  if (!rows.length) return print.info('No documents');
1345
1351
  for (const d of rows) {
1346
1352
  const progress = d.signers ? ' ' + d.signed + '/' + d.signers + ' signed' : '';
1347
- print.info(d.id + ' ' + d.title + ' ' + d.status + progress);
1353
+ print.info(d.id + ' ' + (d.folder ? d.folder + '/' : '') + d.title + ' ' + d.status + progress);
1348
1354
  }
1349
1355
  return;
1350
1356
  }
1357
+ case 'publish': {
1358
+ if (args.length !== 1 || !flags['business-id']) usageExit('coinpay document publish <dir> --business-id <id> [--folder path | --folder /] [--dry-run] [--include-hidden]');
1359
+ if (!existsSync(args[0])) usageExit('No such directory: ' + args[0]);
1360
+ const businessId = flags['business-id'];
1361
+ const dryRun = !!flags['dry-run'];
1362
+ const vault = dryRun ? undefined : await documentsVault(client, businessId);
1363
+ const labels = { upload: 'new', version: 'new version', unchanged: 'unchanged', locked: 'signed, left alone', too_large: 'too large (9 MB max)', empty: 'empty, skipped' };
1364
+ const result = await publishDirectory(client, {
1365
+ businessId,
1366
+ vault,
1367
+ dir: args[0],
1368
+ folder: flags.folder,
1369
+ includeHidden: !!flags['include-hidden'],
1370
+ dryRun,
1371
+ onFile: (p) => {
1372
+ if (flags.json) return;
1373
+ const where = (p.folder ? p.folder + '/' : '') + p.filename;
1374
+ if (p.result === 'ok') print.success((p.action === 'upload' ? 'uploaded ' : 'v' + p.version + ' ') + where);
1375
+ else print.error('failed ' + where + ': ' + p.error);
1376
+ },
1377
+ });
1378
+ if (flags.json) return print.json(result);
1379
+ const count = (a) => result.plan.filter((p) => p.action === a).length;
1380
+ if (dryRun) {
1381
+ for (const p of result.plan) print.info(labels[p.action].padEnd(20) + ' ' + (p.folder ? p.folder + '/' : '') + p.filename);
1382
+ print.info('Dry run: nothing was uploaded.');
1383
+ }
1384
+ const failed = result.plan.filter((p) => p.result === 'failed').length;
1385
+ print.info(
1386
+ 'Into ' + (result.folder ? result.folder + '/' : 'the top level') + ': ' +
1387
+ count('upload') + ' new, ' + count('version') + ' new versions, ' + count('unchanged') + ' unchanged' +
1388
+ (count('locked') ? ', ' + count('locked') + ' signed (left alone)' : '') +
1389
+ (count('too_large') ? ', ' + count('too_large') + ' too large' : '') +
1390
+ (result.skippedHidden ? ', ' + result.skippedHidden + ' hidden skipped' : '') +
1391
+ (failed ? ', ' + failed + ' FAILED' : '')
1392
+ );
1393
+ if (failed) process.exitCode = 1;
1394
+ return;
1395
+ }
1396
+ case 'move': {
1397
+ if (args.length !== 2) usageExit('coinpay document move <id> <folder> (use / for the top level)');
1398
+ const result = await moveDocument(client, args[0], args[1]);
1399
+ return flags.json ? print.json(result) : print.success('Moved to ' + (result.folder || 'the top level'));
1400
+ }
1351
1401
  case 'upload': {
1352
- if (args.length !== 1 || !flags['business-id']) usageExit('coinpay document upload <file> --business-id <id> [--title "..."] [--message "..."]');
1402
+ if (args.length !== 1 || !flags['business-id']) usageExit('coinpay document upload <file> --business-id <id> [--folder path] [--title "..."] [--message "..."]');
1353
1403
  const vault = await documentsVault(client, flags['business-id']);
1354
1404
  const doc = await uploadDocument(client, {
1355
1405
  businessId: flags['business-id'],
@@ -1358,6 +1408,7 @@ async function handleDocument(subcommand, args, flags) {
1358
1408
  filename: args[0],
1359
1409
  title: flags.title,
1360
1410
  message: flags.message,
1411
+ folder: flags.folder,
1361
1412
  });
1362
1413
  if (flags.json) return print.json(doc);
1363
1414
  print.success('Uploaded (encrypted): ' + doc.id);
@@ -1445,7 +1496,7 @@ async function handleDocument(subcommand, args, flags) {
1445
1496
  return print.success('Deleted');
1446
1497
  }
1447
1498
  default:
1448
- usageExit('coinpay document <vault-setup|list|upload|share|get|versions|new-version|link|revoke|download|void|delete>');
1499
+ usageExit('coinpay document <vault-setup|list|upload|publish|move|share|get|versions|new-version|link|revoke|download|void|delete>');
1449
1500
  }
1450
1501
  }
1451
1502
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/coinpay",
3
- "version": "0.21.0",
3
+ "version": "0.23.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",
package/src/client.d.ts CHANGED
@@ -27,6 +27,10 @@ export interface PaymentParams {
27
27
  description?: string;
28
28
  /** Custom metadata attached to the payment (e.g., `{ orderId: 'ORD-123' }`) */
29
29
  metadata?: Record<string, unknown>;
30
+ /** Where the pay page sends the customer once the payment confirms, like Stripe's `success_url`. `{PAYMENT_ID}` is replaced with the payment id. */
31
+ successUrl?: string;
32
+ /** Where "Back to merchant" goes from an expired or abandoned checkout */
33
+ cancelUrl?: string;
30
34
  }
31
35
 
32
36
  /** Parameters for listing payments */
package/src/client.js CHANGED
@@ -228,6 +228,8 @@ export class CoinPayClient {
228
228
  * @param {string} params.blockchain - Blockchain/cryptocurrency (BTC, ETH, SOL, POL, BCH, USDC_ETH, USDC_POL, USDC_SOL)
229
229
  * @param {string} [params.description] - Payment description (shown to customer)
230
230
  * @param {Object} [params.metadata] - Custom metadata (e.g., { orderId: 'ORD-123', customerId: 'CUST-456' })
231
+ * @param {string} [params.successUrl] - Where the pay page sends the customer once the payment confirms, like Stripe's success_url. {PAYMENT_ID} is replaced with the payment id.
232
+ * @param {string} [params.cancelUrl] - Where "Back to merchant" goes from an expired or abandoned checkout
231
233
  * @returns {Promise<Object>} Created payment with address and QR code
232
234
  *
233
235
  * @example
@@ -238,7 +240,8 @@ export class CoinPayClient {
238
240
  * currency: 'USD',
239
241
  * blockchain: 'BTC',
240
242
  * description: 'Order #12345',
241
- * metadata: { orderId: '12345', customerEmail: 'customer@example.com' }
243
+ * metadata: { orderId: '12345', customerEmail: 'customer@example.com' },
244
+ * successUrl: 'https://yoursite.com/orders/12345?payment={PAYMENT_ID}',
242
245
  * });
243
246
  *
244
247
  * // Display payment.payment_address and payment.qr_code to customer
@@ -250,6 +253,8 @@ export class CoinPayClient {
250
253
  blockchain,
251
254
  description,
252
255
  metadata,
256
+ successUrl,
257
+ cancelUrl,
253
258
  }) {
254
259
  // Map to API field names (snake_case)
255
260
  return this.request('/payments/create', {
@@ -261,6 +266,8 @@ export class CoinPayClient {
261
266
  blockchain: blockchain?.toUpperCase(),
262
267
  description,
263
268
  metadata,
269
+ success_url: successUrl,
270
+ cancel_url: cancelUrl,
264
271
  }),
265
272
  });
266
273
  }
@@ -0,0 +1,29 @@
1
+ import type { CoinPayClient } from './client.js';
2
+ import type { CoinPayDocument, UnlockedVault } from './documents.js';
3
+
4
+ export interface PublishEntry {
5
+ abs: string;
6
+ rel: string;
7
+ size: number;
8
+ filename: string;
9
+ folder: string | null;
10
+ sha256?: string;
11
+ action: 'upload' | 'version' | 'unchanged' | 'locked' | 'too_large' | 'empty';
12
+ documentId?: string;
13
+ version?: number;
14
+ result?: 'ok' | 'failed';
15
+ error?: string;
16
+ }
17
+
18
+ export interface PublishResult {
19
+ plan: PublishEntry[];
20
+ skippedHidden: number;
21
+ folder: string | null;
22
+ }
23
+
24
+ export function walkDirectory(dir: string, opts?: { includeHidden?: boolean }): Promise<{ files: { abs: string; rel: string; size: number }[]; skipped: number }>;
25
+ export function planPublish(local: Omit<PublishEntry, 'action'>[], remote: Pick<CoinPayDocument, 'id' | 'folder' | 'filename' | 'sha256' | 'status' | 'signed'>[]): PublishEntry[];
26
+ export function publishDirectory(
27
+ client: CoinPayClient,
28
+ opts: { businessId: string; vault?: UnlockedVault; dir: string; folder?: string; includeHidden?: boolean; dryRun?: boolean; concurrency?: number; onFile?: (entry: PublishEntry) => void }
29
+ ): Promise<PublishResult>;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Publish a local directory to a business's documents: every file is
3
+ * encrypted on this machine (see documents.js) and filed under the same
4
+ * folder structure. Publishing again syncs: new files are uploaded, changed
5
+ * files become a new version, unchanged ones are skipped, and documents that
6
+ * are already signed are never touched. Nothing remote is ever deleted.
7
+ */
8
+
9
+ import { readdir, readFile, stat } from 'node:fs/promises';
10
+ import { basename, join, relative, resolve, sep } from 'node:path';
11
+ import {
12
+ isSkippedPath,
13
+ listDocuments,
14
+ MAX_DOCUMENT_BYTES,
15
+ normalizeFolder,
16
+ sha256Hex,
17
+ uploadDocument,
18
+ uploadDocumentVersion,
19
+ } from './documents.js';
20
+
21
+ /** Every file under `dir`, with its path relative to `dir` ('/'-separated). Symlinks are not followed. */
22
+ export async function walkDirectory(dir, { includeHidden = false } = {}) {
23
+ const root = resolve(dir);
24
+ const out = [];
25
+ let skipped = 0;
26
+ const visit = async (abs) => {
27
+ for (const entry of await readdir(abs, { withFileTypes: true })) {
28
+ const child = join(abs, entry.name);
29
+ const rel = relative(root, child).split(sep).join('/');
30
+ if (!includeHidden && isSkippedPath(rel)) {
31
+ skipped++;
32
+ continue;
33
+ }
34
+ if (entry.isDirectory()) await visit(child);
35
+ else if (entry.isFile()) out.push({ abs: child, rel, size: (await stat(child)).size });
36
+ }
37
+ };
38
+ await visit(root);
39
+ out.sort((a, b) => a.rel.localeCompare(b.rel));
40
+ return { files: out, skipped };
41
+ }
42
+
43
+ const key = (folder, filename) => `${folder ?? ''}\u0000${filename}`;
44
+
45
+ /**
46
+ * Decide what to do with each local file. `remote` is listDocuments() output
47
+ * (newest first, so the first match for a path wins). Pure: no I/O.
48
+ *
49
+ * Actions: upload | version | unchanged | locked (signed, completed, declined
50
+ * or voided remotely) | too_large | empty.
51
+ */
52
+ export function planPublish(local, remote) {
53
+ const byPath = new Map();
54
+ for (const d of remote) if (!byPath.has(key(d.folder, d.filename))) byPath.set(key(d.folder, d.filename), d);
55
+ return local.map((f) => {
56
+ if (f.size === 0) return { ...f, action: 'empty' };
57
+ if (f.size > MAX_DOCUMENT_BYTES) return { ...f, action: 'too_large' };
58
+ const existing = byPath.get(key(f.folder, f.filename));
59
+ if (!existing) return { ...f, action: 'upload' };
60
+ if (existing.sha256 === f.sha256) return { ...f, action: 'unchanged', documentId: existing.id };
61
+ const locked = ['completed', 'declined', 'voided'].includes(existing.status) || (existing.signed ?? 0) > 0;
62
+ return { ...f, action: locked ? 'locked' : 'version', documentId: existing.id, status: existing.status };
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Publish `dir`. `folder` is where its contents go: by default a folder named
68
+ * like the directory (as a desktop copy would); pass '' or '/' for the top level.
69
+ */
70
+ export async function publishDirectory(client, { businessId, vault, dir, folder, includeHidden = false, dryRun = false, concurrency = 3, onFile } = {}) {
71
+ if (!businessId) throw new Error('businessId is required');
72
+ const prefix = normalizeFolder(folder === undefined ? basename(resolve(dir)) : folder);
73
+ const { files, skipped } = await walkDirectory(dir, { includeHidden });
74
+
75
+ const local = [];
76
+ for (const f of files) {
77
+ const parts = f.rel.split('/');
78
+ const filename = parts.pop();
79
+ const entry = { ...f, filename, folder: normalizeFolder([prefix ?? '', ...parts].join('/')) };
80
+ if (f.size > 0 && f.size <= MAX_DOCUMENT_BYTES) entry.sha256 = await sha256Hex(await readFile(f.abs));
81
+ local.push(entry);
82
+ }
83
+ const plan = planPublish(local, await listDocuments(client, { businessId }));
84
+ if (dryRun) return { plan, skippedHidden: skipped, folder: prefix };
85
+
86
+ let next = 0;
87
+ const work = plan.filter((p) => p.action === 'upload' || p.action === 'version');
88
+ const worker = async () => {
89
+ while (next < work.length) {
90
+ const p = work[next++];
91
+ try {
92
+ const bytes = await readFile(p.abs);
93
+ if (p.action === 'upload') {
94
+ const doc = await uploadDocument(client, { businessId, vault, bytes, filename: p.filename, folder: p.folder });
95
+ p.documentId = doc.id;
96
+ } else {
97
+ const result = await uploadDocumentVersion(client, p.documentId, { vault, bytes, filename: p.filename, note: 'published from ' + p.rel });
98
+ p.version = result.version;
99
+ }
100
+ p.result = 'ok';
101
+ } catch (err) {
102
+ p.result = 'failed';
103
+ p.error = err.message;
104
+ }
105
+ onFile?.(p);
106
+ }
107
+ };
108
+ await Promise.all(Array.from({ length: Math.min(concurrency, work.length) }, worker));
109
+ return { plan, skippedHidden: skipped, folder: prefix };
110
+ }
@@ -5,6 +5,7 @@ export interface CoinPayDocument {
5
5
  business_id: string;
6
6
  title: string;
7
7
  message: string | null;
8
+ folder: string | null;
8
9
  filename: string;
9
10
  content_type: string;
10
11
  bytes: number;
@@ -62,12 +63,15 @@ export interface UnlockedVault {
62
63
  version: number;
63
64
  }
64
65
 
65
- export function listDocuments(client: CoinPayClient, opts?: { businessId?: string; status?: string }): Promise<CoinPayDocument[]>;
66
+ export function listDocuments(client: CoinPayClient, opts?: { businessId?: string; status?: string; folder?: string }): Promise<CoinPayDocument[]>;
67
+ export function moveDocument(client: CoinPayClient, documentId: string, folder: string | null): Promise<{ folder: string | null }>;
68
+ export function normalizeFolder(input: string | null | undefined): string | null;
69
+ export function contentTypeFor(filename: string): string;
66
70
  export function getDocument(client: CoinPayClient, documentId: string): Promise<{ document: CoinPayDocument & { signed_available: boolean }; recipients: DocumentRecipient[]; events: unknown[]; versions: DocumentVersion[]; certificate: unknown }>;
67
71
  export function listDocumentVaults(client: CoinPayClient, opts?: { businessId?: string }): Promise<DocumentVault[]>;
68
72
  export function setupDocumentsVault(client: CoinPayClient, opts: { businessId: string; passphrase: string }): Promise<UnlockedVault>;
69
73
  export function unlockDocumentsVault(client: CoinPayClient, opts: { businessId: string; passphrase: string }): Promise<UnlockedVault>;
70
- export function uploadDocument(client: CoinPayClient, opts: { businessId: string; vault: UnlockedVault; bytes: Uint8Array; filename: string; title?: string; message?: string }): Promise<CoinPayDocument>;
74
+ export function uploadDocument(client: CoinPayClient, opts: { businessId: string; vault: UnlockedVault; bytes: Uint8Array; filename: string; title?: string; message?: string; folder?: string | null }): Promise<CoinPayDocument>;
71
75
  export function shareDocument(
72
76
  client: CoinPayClient,
73
77
  documentId: string,
package/src/documents.js CHANGED
@@ -38,6 +38,37 @@ export const DOCUMENT_TYPES = {
38
38
  pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
39
39
  };
40
40
 
41
+ export const GENERIC_CONTENT_TYPE = 'application/octet-stream';
42
+ export const MAX_DOCUMENT_BYTES = 9 * 1024 * 1024;
43
+
44
+ /** Known types preview in the browser; anything else is an opaque download (it is ciphertext to the server). */
45
+ export function contentTypeFor(filename) {
46
+ const name = String(filename);
47
+ const ext = name.includes('.') ? name.toLowerCase().split('.').pop() : '';
48
+ return DOCUMENT_TYPES[ext] ?? GENERIC_CONTENT_TYPE;
49
+ }
50
+
51
+ /** Same rules as the web app (src/lib/documents/types.ts): '/'-joined, no '.', '..' refused, null = top level. */
52
+ export function normalizeFolder(input) {
53
+ if (input == null) return null;
54
+ const segments = String(input)
55
+ .split(/[\\/]+/)
56
+ // eslint-disable-next-line no-control-regex
57
+ .map((s) => s.replace(/[\u0000-\u001f\u007f]/g, '').trim())
58
+ .filter((s) => s && s !== '.');
59
+ if (segments.some((s) => s === '..')) throw new Error('Folder paths cannot contain ".."');
60
+ if (segments.length > 20) throw new Error('Folders can be at most 20 levels deep');
61
+ const joined = segments.map((s) => s.slice(0, 100)).join('/');
62
+ if (joined.length > 1000) throw new Error('Folder path is too long');
63
+ return joined || null;
64
+ }
65
+
66
+ export function isSkippedPath(relativePath) {
67
+ return String(relativePath)
68
+ .split(/[\\/]+/)
69
+ .some((s) => (s.startsWith('.') && s !== '.' && s !== '..') || s === 'node_modules' || s === 'Thumbs.db' || s === 'desktop.ini');
70
+ }
71
+
41
72
  export const AAD = {
42
73
  original: (documentId) => `coinpay-doc:original:${documentId}`,
43
74
  version: (documentId, n) => (n === 1 ? `coinpay-doc:original:${documentId}` : `coinpay-doc:original:${documentId}:v${n}`),
@@ -122,8 +153,8 @@ export function linkWithKey(url, documentKey) {
122
153
  // API
123
154
  // ---------------------------------------------------------------------------
124
155
 
125
- export async function listDocuments(client, { businessId, status } = {}) {
126
- const data = await client.request('/documents' + q({ business_id: businessId, status }));
156
+ export async function listDocuments(client, { businessId, status, folder } = {}) {
157
+ const data = await client.request('/documents' + q({ business_id: businessId, status, folder }));
127
158
  return data.documents ?? [];
128
159
  }
129
160
 
@@ -168,10 +199,8 @@ async function documentKey(client, doc, vault) {
168
199
  }
169
200
 
170
201
  /** Encrypt and upload. `bytes` is the file content; `filename` decides its type. */
171
- export async function uploadDocument(client, { businessId, vault, bytes, filename, title, message }) {
172
- const ext = String(filename).toLowerCase().split('.').pop();
173
- const contentType = DOCUMENT_TYPES[ext];
174
- if (!contentType) throw new Error('Unsupported file type: use ' + Object.keys(DOCUMENT_TYPES).join(', '));
202
+ export async function uploadDocument(client, { businessId, vault, bytes, filename, title, message, folder }) {
203
+ const contentType = contentTypeFor(filename);
175
204
  const plain = new Uint8Array(bytes);
176
205
  const docId = globalThis.crypto.randomUUID();
177
206
  const dk = newKey();
@@ -180,6 +209,7 @@ export async function uploadDocument(client, { businessId, vault, bytes, filenam
180
209
  business_id: businessId,
181
210
  title: title || String(filename).replace(/\.[A-Za-z0-9]{1,5}$/, ''),
182
211
  message: message || null,
212
+ folder: normalizeFolder(folder),
183
213
  filename: String(filename).split(/[\\/]/).pop(),
184
214
  content_type: contentType,
185
215
  bytes: plain.length,
@@ -230,6 +260,11 @@ export async function voidDocument(client, documentId, { reason } = {}) {
230
260
  return client.request('/documents/' + id(documentId), json('PATCH', { action: 'void', ...(reason ? { reason } : {}) }));
231
261
  }
232
262
 
263
+ /** File a document under another folder (null = top level). */
264
+ export async function moveDocument(client, documentId, folder) {
265
+ return client.request('/documents/' + id(documentId), json('PATCH', { action: 'move', folder: normalizeFolder(folder) }));
266
+ }
267
+
233
268
  export async function deleteDocument(client, documentId) {
234
269
  return client.request('/documents/' + id(documentId), { method: 'DELETE' });
235
270
  }
@@ -246,9 +281,7 @@ export async function listDocumentVersions(client, documentId) {
246
281
  */
247
282
  export async function uploadDocumentVersion(client, documentId, { vault, bytes, filename, note }) {
248
283
  const doc = (await getDocument(client, documentId)).document;
249
- const ext = String(filename).toLowerCase().split('.').pop();
250
- const contentType = DOCUMENT_TYPES[ext];
251
- if (!contentType) throw new Error('Unsupported file type: use ' + Object.keys(DOCUMENT_TYPES).join(', '));
284
+ const contentType = contentTypeFor(filename);
252
285
  const dk = await documentKey(client, doc, vault);
253
286
  const plain = new Uint8Array(bytes);
254
287
  const next = doc.current_version + 1;
package/src/index.d.ts CHANGED
@@ -129,7 +129,10 @@ export {
129
129
  downloadDocument,
130
130
  listDocumentVersions,
131
131
  uploadDocumentVersion,
132
+ moveDocument,
132
133
  } from './documents.js';
134
+ export { publishDirectory, planPublish } from './documents-publish.js';
135
+ export type { PublishEntry, PublishResult } from './documents-publish.js';
133
136
  export type { CoinPayDocument, DocumentRecipient, DocumentVault, DocumentVersion, UnlockedVault } from './documents.js';
134
137
 
135
138
  // Webhook exports
package/src/index.js CHANGED
@@ -143,7 +143,9 @@ import {
143
143
  downloadDocument,
144
144
  listDocumentVersions,
145
145
  uploadDocumentVersion,
146
+ moveDocument,
146
147
  } from './documents.js';
148
+ import { publishDirectory, planPublish } from './documents-publish.js';
147
149
 
148
150
  // Wallet exports
149
151
  import {
@@ -343,6 +345,9 @@ export {
343
345
  downloadDocument,
344
346
  listDocumentVersions,
345
347
  uploadDocumentVersion,
348
+ moveDocument,
349
+ publishDirectory,
350
+ planPublish,
346
351
 
347
352
  // Wallet
348
353
  WalletClient,
package/src/payments.d.ts CHANGED
@@ -25,6 +25,10 @@ export interface CreatePaymentParams {
25
25
  description?: string;
26
26
  /** Custom metadata for your records */
27
27
  metadata?: Record<string, unknown>;
28
+ /** Where the pay page sends the customer once the payment confirms, like Stripe's `success_url`. `{PAYMENT_ID}` is replaced with the payment id. */
29
+ successUrl?: string;
30
+ /** Where "Back to merchant" goes from an expired or abandoned checkout */
31
+ cancelUrl?: string;
28
32
  }
29
33
 
30
34
  /** Parameters for the standalone `getPayment` function */
package/src/payments.js CHANGED
@@ -34,6 +34,8 @@ import { CoinPayClient } from './client.js';
34
34
  * @param {string} params.blockchain - Blockchain to use (BTC, ETH, SOL, POL, BCH, USDC_ETH, USDC_POL, USDC_SOL)
35
35
  * @param {string} [params.description] - Payment description shown to customer
36
36
  * @param {Object} [params.metadata] - Custom metadata for your records
37
+ * @param {string} [params.successUrl] - Where the pay page sends the customer after the payment confirms ({PAYMENT_ID} is filled in)
38
+ * @param {string} [params.cancelUrl] - Where "Back to merchant" goes from an expired checkout
37
39
  * @returns {Promise<Object>} Created payment with address and QR code
38
40
  *
39
41
  * @example
@@ -60,6 +62,8 @@ export async function createPayment({
60
62
  blockchain,
61
63
  description,
62
64
  metadata,
65
+ successUrl,
66
+ cancelUrl,
63
67
  }) {
64
68
  const coinpay = client || new CoinPayClient({ apiKey });
65
69
 
@@ -70,6 +74,8 @@ export async function createPayment({
70
74
  blockchain,
71
75
  description,
72
76
  metadata,
77
+ successUrl,
78
+ cancelUrl,
73
79
  });
74
80
  }
75
81