@profullstack/coinpay 0.16.0 → 0.18.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 +39 -0
- package/bin/coinpay.js +448 -7
- package/package.json +1 -1
- package/src/index.d.ts +29 -0
- package/src/index.js +42 -0
- package/src/invoices.d.ts +72 -1
- package/src/invoices.js +71 -3
- package/src/mcp.js +103 -0
- package/src/projects.d.ts +91 -0
- package/src/projects.js +106 -0
- package/src/x402.js +24 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,45 @@ 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.18.0] - 2026-10-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Projects and time tracking: `coinpay project list|create|get|invoice`,
|
|
12
|
+
`coinpay time log|start|stop|list`. `project invoice` bills unbilled
|
|
13
|
+
billable time as a draft itemised invoice; billed time is locked.
|
|
14
|
+
- `coinpay invoice aging`: unpaid sent invoices by days past due, per
|
|
15
|
+
currency and per client.
|
|
16
|
+
- `coinpay invoice settings`: reminder schedule (`--reminders "-3,0,7"`),
|
|
17
|
+
late fee (`--late-fee 1.5%` or `25`, `--late-fee-after 3`) and branding
|
|
18
|
+
(`--brand-color`, `--logo-url`, `--footer`).
|
|
19
|
+
- `coinpay client portal-link <id>`: one link where a client sees and pays
|
|
20
|
+
all their invoices; `--revoke` turns it off.
|
|
21
|
+
- MCP: `invoice_list`, `invoice_get`, `invoice_create_draft`,
|
|
22
|
+
`invoice_ar_aging`, `project_list`, `project_get`, `time_log`,
|
|
23
|
+
`project_invoice_time`.
|
|
24
|
+
- SDK: `getArAging`, `getInvoiceSettings`, `updateInvoiceSettings`,
|
|
25
|
+
`createClientPortalLink`, `revokeClientPortalLink`, `listProjects`,
|
|
26
|
+
`getProject`, `createProject`, `logTime`, `startTimer`, `stopTimer`,
|
|
27
|
+
`listTimeEntries`, `invoiceProjectTime`.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- A flag value that starts with a minus and a digit (`--reminders -3,0,7`)
|
|
31
|
+
is read as the value instead of as another flag.
|
|
32
|
+
|
|
33
|
+
## [0.17.0] - 2026-10-17
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- Itemised invoices: `createInvoice` / `updateInvoice` take `lineItems`
|
|
37
|
+
(description, quantity, unit price, optional tax rate) and a `discount`
|
|
38
|
+
(`percent` or `fixed`, applied before tax). The server computes the total;
|
|
39
|
+
`amount` is only for single-amount invoices.
|
|
40
|
+
- `coinpay invoice create --item "Desc|qty|price[|tax %[|tax name]]"`
|
|
41
|
+
(repeatable) and `--discount 10%` / `--discount 25`.
|
|
42
|
+
- Saved products/services and tax rates: `coinpay item list|create|delete`,
|
|
43
|
+
`coinpay tax-rate list|create|delete`; SDK `listInvoiceItems`,
|
|
44
|
+
`createInvoiceItem`, `deleteInvoiceItem`, `listTaxRates`, `createTaxRate`,
|
|
45
|
+
`deleteTaxRate`.
|
|
46
|
+
|
|
8
47
|
## [0.16.0] - 2026-10-12
|
|
9
48
|
|
|
10
49
|
### Added
|
package/bin/coinpay.js
CHANGED
|
@@ -24,7 +24,28 @@ import {
|
|
|
24
24
|
sendInvoice,
|
|
25
25
|
updateInvoice,
|
|
26
26
|
InvoiceStatus,
|
|
27
|
+
listTaxRates,
|
|
28
|
+
createTaxRate,
|
|
29
|
+
deleteTaxRate,
|
|
30
|
+
listInvoiceItems,
|
|
31
|
+
createInvoiceItem,
|
|
32
|
+
deleteInvoiceItem,
|
|
27
33
|
} from '../src/invoices.js';
|
|
34
|
+
import {
|
|
35
|
+
getArAging,
|
|
36
|
+
getInvoiceSettings,
|
|
37
|
+
updateInvoiceSettings,
|
|
38
|
+
createClientPortalLink,
|
|
39
|
+
revokeClientPortalLink,
|
|
40
|
+
listProjects,
|
|
41
|
+
getProject,
|
|
42
|
+
createProject,
|
|
43
|
+
logTime,
|
|
44
|
+
startTimer,
|
|
45
|
+
stopTimer,
|
|
46
|
+
listTimeEntries,
|
|
47
|
+
invoiceProjectTime,
|
|
48
|
+
} from '../src/projects.js';
|
|
28
49
|
import { readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from 'fs';
|
|
29
50
|
import { execSync, spawn } from 'child_process';
|
|
30
51
|
import { createInterface } from 'readline';
|
|
@@ -167,6 +188,11 @@ function createUnauthenticatedClient() {
|
|
|
167
188
|
}
|
|
168
189
|
|
|
169
190
|
const BOOLEAN_FLAGS = new Set([
|
|
191
|
+
// projects, time, client portal
|
|
192
|
+
'non-billable',
|
|
193
|
+
'unbilled',
|
|
194
|
+
'archived',
|
|
195
|
+
'revoke',
|
|
170
196
|
'active',
|
|
171
197
|
// finances reports / statements
|
|
172
198
|
'wait',
|
|
@@ -206,8 +232,18 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
206
232
|
/**
|
|
207
233
|
* Parse command line arguments
|
|
208
234
|
*/
|
|
235
|
+
// Flags that may be given more than once collect into an array.
|
|
236
|
+
const REPEATABLE_FLAGS = new Set(['item']);
|
|
237
|
+
|
|
209
238
|
function parseArgs(args) {
|
|
210
239
|
const result = { command: null, subcommand: null, args: [], flags: {} };
|
|
240
|
+
const setFlag = (name, value) => {
|
|
241
|
+
if (REPEATABLE_FLAGS.has(name) && value !== true) {
|
|
242
|
+
result.flags[name] = [...(Array.isArray(result.flags[name]) ? result.flags[name] : []), value];
|
|
243
|
+
} else {
|
|
244
|
+
result.flags[name] = value;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
211
247
|
|
|
212
248
|
for (let i = 0; i < args.length; i++) {
|
|
213
249
|
const arg = args[i];
|
|
@@ -215,14 +251,14 @@ function parseArgs(args) {
|
|
|
215
251
|
if (arg.startsWith('--')) {
|
|
216
252
|
const eqIdx = arg.indexOf('=');
|
|
217
253
|
if (eqIdx !== -1) {
|
|
218
|
-
|
|
254
|
+
setFlag(arg.slice(2, eqIdx), arg.slice(eqIdx + 1));
|
|
219
255
|
} else {
|
|
220
256
|
const name = arg.slice(2);
|
|
221
257
|
const next = args[i + 1];
|
|
222
258
|
if (BOOLEAN_FLAGS.has(name)) {
|
|
223
259
|
result.flags[name] = true;
|
|
224
|
-
} else if (next && !next.startsWith('-')) {
|
|
225
|
-
|
|
260
|
+
} else if (next && (!next.startsWith('-') || /^-\d/.test(next))) {
|
|
261
|
+
setFlag(name, next);
|
|
226
262
|
i++;
|
|
227
263
|
} else {
|
|
228
264
|
result.flags[name] = true;
|
|
@@ -289,6 +325,40 @@ function isValidDate(value) {
|
|
|
289
325
|
);
|
|
290
326
|
}
|
|
291
327
|
|
|
328
|
+
function parseItemFlags(values) {
|
|
329
|
+
return values.map((value) => {
|
|
330
|
+
const [description, qty, price, tax, taxName] = String(value).split('|').map((p) => p.trim());
|
|
331
|
+
const quantity = Number(qty);
|
|
332
|
+
const unitPrice = parseNonNegativeAmount(price);
|
|
333
|
+
const taxRate = tax ? Number(tax) : null;
|
|
334
|
+
if (!description || !(quantity > 0) || unitPrice === null || (tax && !(taxRate >= 0 && taxRate <= 100))) {
|
|
335
|
+
print.error('--item must look like "Description|qty|unit price[|tax %[|tax name]]", got: ' + value);
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
description,
|
|
340
|
+
quantity,
|
|
341
|
+
unit_price: unitPrice,
|
|
342
|
+
...(taxRate ? { tax_rate: taxRate, tax_name: taxName || null } : {}),
|
|
343
|
+
};
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function parseNonNegativeAmount(value) {
|
|
348
|
+
if (typeof value !== 'string' || !/^\d+(\.\d{1,2})?$/.test(value)) return null;
|
|
349
|
+
return Number(value);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** "10%" is a percent discount, "25" a fixed one in the invoice currency. */
|
|
353
|
+
function parseDiscountFlag(value) {
|
|
354
|
+
const match = /^(\d+(?:\.\d+)?)(%?)$/.exec(String(value).trim());
|
|
355
|
+
if (!match || !(Number(match[1]) > 0)) {
|
|
356
|
+
print.error('--discount must be a percent like 10% or a fixed amount like 25');
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
return { type: match[2] ? 'percent' : 'fixed', value: Number(match[1]) };
|
|
360
|
+
}
|
|
361
|
+
|
|
292
362
|
function requireFlagValues(flags, names) {
|
|
293
363
|
for (const name of names) {
|
|
294
364
|
if (flags[name] === true) {
|
|
@@ -325,6 +395,10 @@ function invoiceForOutput(data, fallbackId) {
|
|
|
325
395
|
clientId: invoice.clientId ?? invoice.client_id,
|
|
326
396
|
currency: invoice.currency,
|
|
327
397
|
amount: invoice.amount,
|
|
398
|
+
lineItems: invoice.lineItems ?? invoice.line_items ?? null,
|
|
399
|
+
subtotal: invoice.subtotal ?? null,
|
|
400
|
+
discountTotal: invoice.discountTotal ?? invoice.discount_total ?? null,
|
|
401
|
+
taxTotal: invoice.taxTotal ?? invoice.tax_total ?? null,
|
|
328
402
|
cryptoCurrency: invoice.cryptoCurrency ?? invoice.crypto_currency,
|
|
329
403
|
cryptoAmount: invoice.cryptoAmount ?? invoice.crypto_amount,
|
|
330
404
|
status: invoice.status,
|
|
@@ -403,7 +477,9 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
403
477
|
qr <id> Get payment QR code
|
|
404
478
|
|
|
405
479
|
${colors.bright}invoice${colors.reset}
|
|
406
|
-
create Create a draft invoice (--amount
|
|
480
|
+
create Create a draft invoice (--amount, or one or more
|
|
481
|
+
--item "Desc|qty|price[|tax %[|tax name]]" and
|
|
482
|
+
an optional --discount 10% / --discount 25)
|
|
407
483
|
--wallet-id <id> Route settlement through a saved wallet
|
|
408
484
|
--merchant-wallet-address <address>
|
|
409
485
|
Route settlement to a direct address
|
|
@@ -415,6 +491,38 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
415
491
|
delete <id> Permanently delete a draft invoice
|
|
416
492
|
--yes Skip confirmation for publish/send/delete
|
|
417
493
|
|
|
494
|
+
${colors.bright}invoice aging${colors.reset} Unpaid invoices by days past due (--business-id)
|
|
495
|
+
${colors.bright}invoice settings${colors.reset} Show reminders, late fee, branding; change with
|
|
496
|
+
--reminders "-3,0,7" | off, --late-fee 1.5% | 25 | none,
|
|
497
|
+
--late-fee-after 3, --brand-color "#7c3aed", --logo-url, --footer
|
|
498
|
+
|
|
499
|
+
${colors.bright}client${colors.reset}
|
|
500
|
+
portal-link <client-id> Create a portal link listing all their invoices
|
|
501
|
+
(replaces the old link; --revoke to turn it off)
|
|
502
|
+
|
|
503
|
+
${colors.bright}project${colors.reset}
|
|
504
|
+
list Projects (--business-id, --archived)
|
|
505
|
+
create --name, [--client-id] [--rate 120] [--budget 40]
|
|
506
|
+
get <id> Hours, unbilled, billed and paid
|
|
507
|
+
invoice <id> Bill unbilled time as a draft invoice
|
|
508
|
+
|
|
509
|
+
${colors.bright}time${colors.reset}
|
|
510
|
+
log --project <id> --minutes 90 | --hours 1.5 [--description] [--non-billable]
|
|
511
|
+
start --project <id> [--description] start a timer
|
|
512
|
+
stop stop your running timer
|
|
513
|
+
list --project <id> [--unbilled]
|
|
514
|
+
|
|
515
|
+
${colors.bright}item${colors.reset}
|
|
516
|
+
list Saved products/services (--business-id)
|
|
517
|
+
create Save one: --name, --price [--unit hour] [--description]
|
|
518
|
+
[--tax-rate-id <id>]
|
|
519
|
+
delete <id> Delete a saved item (issued invoices keep their copy)
|
|
520
|
+
|
|
521
|
+
${colors.bright}tax-rate${colors.reset}
|
|
522
|
+
list Saved tax rates (--business-id)
|
|
523
|
+
create Save one: --name "CA Sales Tax" --rate 8.25 [--default]
|
|
524
|
+
delete <id> Delete a saved tax rate
|
|
525
|
+
|
|
418
526
|
${colors.bright}tokens${colors.reset}
|
|
419
527
|
list List checkout tokens (--business-id, --active-only)
|
|
420
528
|
|
|
@@ -1068,6 +1176,15 @@ function printInvoiceDetails(invoice, { shareUrl } = {}) {
|
|
|
1068
1176
|
console.log('\n' + colors.bright + label + colors.reset);
|
|
1069
1177
|
print.info('ID: ' + invoice.id);
|
|
1070
1178
|
print.info('Status: ' + (invoice.status || 'unknown'));
|
|
1179
|
+
if (Array.isArray(invoice.lineItems) && invoice.lineItems.length > 0) {
|
|
1180
|
+
for (const line of invoice.lineItems) {
|
|
1181
|
+
const tax = line.tax_rate ? ' +' + line.tax_rate + '% ' + (line.tax_name || 'tax') : '';
|
|
1182
|
+
print.info(' ' + line.description.split('\n')[0] + ': ' + line.quantity + ' x ' + line.unit_price + ' = ' + line.amount + tax);
|
|
1183
|
+
}
|
|
1184
|
+
print.info('Subtotal: ' + invoice.subtotal);
|
|
1185
|
+
if (Number(invoice.discountTotal) > 0) print.info('Discount: -' + invoice.discountTotal);
|
|
1186
|
+
if (Number(invoice.taxTotal) > 0) print.info('Tax: ' + invoice.taxTotal);
|
|
1187
|
+
}
|
|
1071
1188
|
print.info('Amount: ' + invoice.amount + ' ' + (invoice.currency || ''));
|
|
1072
1189
|
if (invoice.businessId) print.info('Business: ' + invoice.businessId);
|
|
1073
1190
|
if (invoice.clientId) print.info('Client: ' + invoice.clientId);
|
|
@@ -1088,6 +1205,227 @@ function printInvoiceDetails(invoice, { shareUrl } = {}) {
|
|
|
1088
1205
|
/**
|
|
1089
1206
|
* Invoice commands
|
|
1090
1207
|
*/
|
|
1208
|
+
async function handleClientCommand(subcommand, args, flags) {
|
|
1209
|
+
const client = createClient();
|
|
1210
|
+
if (subcommand !== 'portal-link' || args.length !== 1) {
|
|
1211
|
+
print.error('Usage: coinpay client portal-link <client-id> [--revoke]');
|
|
1212
|
+
process.exit(1);
|
|
1213
|
+
}
|
|
1214
|
+
if (flags.revoke) {
|
|
1215
|
+
await revokeClientPortalLink(client, args[0]);
|
|
1216
|
+
print.success('Portal link revoked');
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
const url = await createClientPortalLink(client, args[0]);
|
|
1220
|
+
if (flags.json) print.json({ url });
|
|
1221
|
+
else {
|
|
1222
|
+
print.success('Portal link (any earlier link no longer works):');
|
|
1223
|
+
console.log(url);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function hoursOf(minutes) {
|
|
1228
|
+
return minutes == null ? 'running' : Math.floor(minutes / 60) + 'h' + String(minutes % 60).padStart(2, '0') + 'm';
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
async function handleProject(subcommand, args, flags) {
|
|
1232
|
+
const client = createClient();
|
|
1233
|
+
requireFlagValues(flags, ['business-id', 'name', 'client-id', 'rate', 'budget', 'currency']);
|
|
1234
|
+
switch (subcommand) {
|
|
1235
|
+
case 'list': {
|
|
1236
|
+
const rows = await listProjects(client, { businessId: flags['business-id'], includeArchived: !!flags.archived });
|
|
1237
|
+
if (flags.json) return print.json(rows);
|
|
1238
|
+
if (!rows.length) return print.info('No projects');
|
|
1239
|
+
for (const p of rows) {
|
|
1240
|
+
print.info(p.id + ' ' + p.name + (p.hourly_rate != null ? ' ' + p.hourly_rate + '/hr' : '') + (p.status === 'archived' ? ' (archived)' : ''));
|
|
1241
|
+
}
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
case 'create': {
|
|
1245
|
+
if (!flags.name) {
|
|
1246
|
+
print.error('--name is required');
|
|
1247
|
+
process.exit(1);
|
|
1248
|
+
}
|
|
1249
|
+
const rate = flags.rate === undefined ? undefined : parseNonNegativeAmount(String(flags.rate));
|
|
1250
|
+
if (rate === null) {
|
|
1251
|
+
print.error('--rate must be an amount with at most two decimals');
|
|
1252
|
+
process.exit(1);
|
|
1253
|
+
}
|
|
1254
|
+
const project = await createProject(client, {
|
|
1255
|
+
businessId: flags['business-id'],
|
|
1256
|
+
name: flags.name,
|
|
1257
|
+
clientId: flags['client-id'],
|
|
1258
|
+
hourlyRate: rate,
|
|
1259
|
+
currency: flags.currency?.toUpperCase(),
|
|
1260
|
+
budgetHours: flags.budget === undefined ? undefined : Number(flags.budget),
|
|
1261
|
+
});
|
|
1262
|
+
if (flags.json) return print.json(project);
|
|
1263
|
+
return print.success('Project created: ' + project.id);
|
|
1264
|
+
}
|
|
1265
|
+
case 'get': {
|
|
1266
|
+
if (args.length !== 1) {
|
|
1267
|
+
print.error('Usage: coinpay project get <id>');
|
|
1268
|
+
process.exit(1);
|
|
1269
|
+
}
|
|
1270
|
+
const { project, stats } = await getProject(client, args[0]);
|
|
1271
|
+
if (flags.json) return print.json({ project, stats });
|
|
1272
|
+
print.info(project.name + (project.hourly_rate != null ? ' ' + project.hourly_rate + '/hr' : ''));
|
|
1273
|
+
print.info('Hours: ' + stats.hours + (project.budget_hours ? ' of ' + project.budget_hours : '') + ' (' + stats.billable_hours + ' billable)');
|
|
1274
|
+
print.info('Unbilled: ' + stats.unbilled_amount + ' Billed: ' + stats.billed_amount + ' Paid: ' + stats.paid_amount);
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
case 'invoice': {
|
|
1278
|
+
if (args.length !== 1) {
|
|
1279
|
+
print.error('Usage: coinpay project invoice <id>');
|
|
1280
|
+
process.exit(1);
|
|
1281
|
+
}
|
|
1282
|
+
const result = await invoiceProjectTime(client, args[0]);
|
|
1283
|
+
if (flags.json) return print.json(result);
|
|
1284
|
+
print.success('Draft ' + result.invoice.invoice_number + ' for ' + result.invoice.amount + ' from ' + result.entries_billed + ' entries');
|
|
1285
|
+
return print.info('Review and send: coinpay invoice send ' + result.invoice.id);
|
|
1286
|
+
}
|
|
1287
|
+
default:
|
|
1288
|
+
print.error('Usage: coinpay project <list|create|get|invoice>');
|
|
1289
|
+
process.exit(1);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
async function handleTime(subcommand, args, flags) {
|
|
1294
|
+
const client = createClient();
|
|
1295
|
+
requireFlagValues(flags, ['project', 'minutes', 'hours', 'description', 'business-id']);
|
|
1296
|
+
switch (subcommand) {
|
|
1297
|
+
case 'log': {
|
|
1298
|
+
const minutes = flags.minutes !== undefined ? Number(flags.minutes) : Math.round(Number(flags.hours) * 60);
|
|
1299
|
+
if (!flags.project || !Number.isInteger(minutes) || minutes < 1) {
|
|
1300
|
+
print.error('Usage: coinpay time log --project <id> --minutes 90 (or --hours 1.5) [--description "..."]');
|
|
1301
|
+
process.exit(1);
|
|
1302
|
+
}
|
|
1303
|
+
const entry = await logTime(client, {
|
|
1304
|
+
projectId: flags.project,
|
|
1305
|
+
minutes,
|
|
1306
|
+
description: flags.description,
|
|
1307
|
+
billable: flags['non-billable'] ? false : undefined,
|
|
1308
|
+
});
|
|
1309
|
+
if (flags.json) return print.json(entry);
|
|
1310
|
+
return print.success('Logged ' + hoursOf(entry.minutes));
|
|
1311
|
+
}
|
|
1312
|
+
case 'start': {
|
|
1313
|
+
if (!flags.project) {
|
|
1314
|
+
print.error('Usage: coinpay time start --project <id> [--description "..."]');
|
|
1315
|
+
process.exit(1);
|
|
1316
|
+
}
|
|
1317
|
+
const entry = await startTimer(client, { projectId: flags.project, description: flags.description });
|
|
1318
|
+
if (flags.json) return print.json(entry);
|
|
1319
|
+
return print.success('Timer started (' + entry.id + ')');
|
|
1320
|
+
}
|
|
1321
|
+
case 'stop': {
|
|
1322
|
+
const entry = await stopTimer(client, { entryId: args[0], businessId: flags['business-id'] });
|
|
1323
|
+
if (flags.json) return print.json(entry);
|
|
1324
|
+
return print.success('Timer stopped: ' + hoursOf(entry.minutes));
|
|
1325
|
+
}
|
|
1326
|
+
case 'list': {
|
|
1327
|
+
const rows = await listTimeEntries(client, { projectId: flags.project, businessId: flags['business-id'], unbilled: !!flags.unbilled });
|
|
1328
|
+
if (flags.json) return print.json(rows);
|
|
1329
|
+
if (!rows.length) return print.info('No time logged');
|
|
1330
|
+
for (const e of rows) {
|
|
1331
|
+
print.info(
|
|
1332
|
+
e.started_at.slice(0, 10) + ' ' + hoursOf(e.minutes).padStart(8) + ' ' + (e.invoice_id ? 'billed ' : e.billable ? 'unbilled' : 'internal') + ' ' + (e.description || '')
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
default:
|
|
1338
|
+
print.error('Usage: coinpay time <log|start|stop|list>');
|
|
1339
|
+
process.exit(1);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
async function handleCatalog(kind, subcommand, args, flags) {
|
|
1344
|
+
const client = createClient();
|
|
1345
|
+
const isItem = kind === 'item';
|
|
1346
|
+
requireFlagValues(flags, ['business-id', 'name', 'price', 'unit', 'description', 'tax-rate-id', 'rate']);
|
|
1347
|
+
|
|
1348
|
+
switch (subcommand) {
|
|
1349
|
+
case 'list': {
|
|
1350
|
+
const rows = isItem
|
|
1351
|
+
? await listInvoiceItems(client, { businessId: flags['business-id'] })
|
|
1352
|
+
: await listTaxRates(client, { businessId: flags['business-id'] });
|
|
1353
|
+
if (flags.json) {
|
|
1354
|
+
print.json(rows);
|
|
1355
|
+
break;
|
|
1356
|
+
}
|
|
1357
|
+
if (rows.length === 0) {
|
|
1358
|
+
print.info(isItem ? 'No saved items' : 'No saved tax rates');
|
|
1359
|
+
break;
|
|
1360
|
+
}
|
|
1361
|
+
for (const row of rows) {
|
|
1362
|
+
print.info(
|
|
1363
|
+
isItem
|
|
1364
|
+
? row.id + ' ' + row.name + ' ' + row.unit_price + (row.unit ? ' / ' + row.unit : '')
|
|
1365
|
+
: row.id + ' ' + row.name + ' ' + row.rate + '%' + (row.is_default ? ' (default)' : '')
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
break;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
case 'create': {
|
|
1372
|
+
if (!flags.name) {
|
|
1373
|
+
print.error('--name is required');
|
|
1374
|
+
process.exit(1);
|
|
1375
|
+
}
|
|
1376
|
+
let row;
|
|
1377
|
+
if (isItem) {
|
|
1378
|
+
const unitPrice = parseNonNegativeAmount(flags.price);
|
|
1379
|
+
if (unitPrice === null) {
|
|
1380
|
+
print.error('--price must be a decimal with at most two decimal places');
|
|
1381
|
+
process.exit(1);
|
|
1382
|
+
}
|
|
1383
|
+
row = await createInvoiceItem(client, {
|
|
1384
|
+
businessId: flags['business-id'],
|
|
1385
|
+
name: flags.name,
|
|
1386
|
+
unitPrice,
|
|
1387
|
+
description: flags.description,
|
|
1388
|
+
unit: flags.unit,
|
|
1389
|
+
taxRateId: flags['tax-rate-id'],
|
|
1390
|
+
});
|
|
1391
|
+
} else {
|
|
1392
|
+
const rate = Number(flags.rate);
|
|
1393
|
+
if (flags.rate === undefined || !(rate >= 0 && rate <= 100)) {
|
|
1394
|
+
print.error('--rate must be a percent between 0 and 100');
|
|
1395
|
+
process.exit(1);
|
|
1396
|
+
}
|
|
1397
|
+
row = await createTaxRate(client, {
|
|
1398
|
+
businessId: flags['business-id'],
|
|
1399
|
+
name: flags.name,
|
|
1400
|
+
rate,
|
|
1401
|
+
isDefault: flags.default === true || flags.default === 'true',
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
if (flags.json) {
|
|
1405
|
+
print.json(row);
|
|
1406
|
+
break;
|
|
1407
|
+
}
|
|
1408
|
+
print.success((isItem ? 'Item' : 'Tax rate') + ' saved: ' + row.id);
|
|
1409
|
+
break;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
case 'delete': {
|
|
1413
|
+
if (args.length !== 1) {
|
|
1414
|
+
print.error('Usage: coinpay ' + kind + ' delete <id>');
|
|
1415
|
+
process.exit(1);
|
|
1416
|
+
}
|
|
1417
|
+
if (isItem) await deleteInvoiceItem(client, args[0]);
|
|
1418
|
+
else await deleteTaxRate(client, args[0]);
|
|
1419
|
+
print.success('Deleted ' + args[0]);
|
|
1420
|
+
break;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
default:
|
|
1424
|
+
print.error('Usage: coinpay ' + kind + ' <list|create|delete>');
|
|
1425
|
+
process.exit(1);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1091
1429
|
async function handleInvoice(subcommand, args, flags) {
|
|
1092
1430
|
const client = createClient();
|
|
1093
1431
|
|
|
@@ -1103,12 +1441,23 @@ async function handleInvoice(subcommand, args, flags) {
|
|
|
1103
1441
|
'notes',
|
|
1104
1442
|
'wallet-id',
|
|
1105
1443
|
'merchant-wallet-address',
|
|
1444
|
+
'item',
|
|
1445
|
+
'discount',
|
|
1106
1446
|
]);
|
|
1107
1447
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1448
|
+
// --item "Description|qty|unit price[|tax %[|tax name]]", repeatable.
|
|
1449
|
+
const lineItems = flags.item === undefined ? undefined : parseItemFlags(flags.item);
|
|
1450
|
+
const discount = flags.discount === undefined ? undefined : parseDiscountFlag(flags.discount);
|
|
1451
|
+
if (discount && !lineItems) {
|
|
1452
|
+
print.error('--discount applies to itemised invoices; add one or more --item');
|
|
1453
|
+
process.exit(1);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
const amount = lineItems ? undefined : parsePositiveAmount(flags.amount);
|
|
1457
|
+
if (!lineItems && amount === null) {
|
|
1110
1458
|
print.error('--amount must be a positive decimal with at most two decimal places');
|
|
1111
1459
|
print.info('Example: coinpay invoice create --amount 125 --currency USD --notes "Consulting"');
|
|
1460
|
+
print.info('Or itemise: coinpay invoice create --item "Design|3|100" --item "Hosting|1|19.99|8.25|CA tax"');
|
|
1112
1461
|
process.exit(1);
|
|
1113
1462
|
}
|
|
1114
1463
|
|
|
@@ -1130,6 +1479,8 @@ async function handleInvoice(subcommand, args, flags) {
|
|
|
1130
1479
|
clientId: flags['client-id'],
|
|
1131
1480
|
currency: currency.toUpperCase(),
|
|
1132
1481
|
amount,
|
|
1482
|
+
lineItems,
|
|
1483
|
+
discount,
|
|
1133
1484
|
cryptoCurrency: cryptoCurrency?.toUpperCase(),
|
|
1134
1485
|
dueDate,
|
|
1135
1486
|
notes: flags.notes,
|
|
@@ -1511,9 +1862,82 @@ async function handleInvoice(subcommand, args, flags) {
|
|
|
1511
1862
|
break;
|
|
1512
1863
|
}
|
|
1513
1864
|
|
|
1865
|
+
case 'aging': {
|
|
1866
|
+
requireFlagValues(flags, ['business-id']);
|
|
1867
|
+
const report = await getArAging(client, { businessId: flags['business-id'] });
|
|
1868
|
+
if (flags.json) {
|
|
1869
|
+
print.json(report);
|
|
1870
|
+
break;
|
|
1871
|
+
}
|
|
1872
|
+
if (!report.totals?.length) {
|
|
1873
|
+
print.info('Nothing outstanding');
|
|
1874
|
+
break;
|
|
1875
|
+
}
|
|
1876
|
+
const cols = ['current', '1_30', '31_60', '61_90', '90_plus', 'total'];
|
|
1877
|
+
print.info(' current 1-30 31-60 61-90 90+ total');
|
|
1878
|
+
for (const row of [...report.totals.map((t) => ({ ...t, name: 'TOTAL ' + t.currency })), ...report.clients]) {
|
|
1879
|
+
print.info(
|
|
1880
|
+
String(row.name).slice(0, 24).padEnd(24) +
|
|
1881
|
+
cols.map((c) => String(row[c] ? row[c].toFixed(2) : '-').padStart(9)).join('')
|
|
1882
|
+
);
|
|
1883
|
+
}
|
|
1884
|
+
break;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
case 'settings': {
|
|
1888
|
+
requireFlagValues(flags, ['business-id', 'reminders', 'late-fee', 'late-fee-after', 'brand-color', 'logo-url', 'footer']);
|
|
1889
|
+
const changes = {};
|
|
1890
|
+
if (flags.reminders !== undefined) {
|
|
1891
|
+
if (flags.reminders === 'off') changes.reminders_enabled = false;
|
|
1892
|
+
else {
|
|
1893
|
+
const days = String(flags.reminders).split(',').map((d) => Number(d.trim()));
|
|
1894
|
+
if (days.some((d) => !Number.isInteger(d))) {
|
|
1895
|
+
print.error('--reminders must be days relative to the due date, like "-3,0,7", or off');
|
|
1896
|
+
process.exit(1);
|
|
1897
|
+
}
|
|
1898
|
+
changes.reminders_enabled = true;
|
|
1899
|
+
changes.reminder_days = days;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
if (flags['late-fee'] !== undefined) {
|
|
1903
|
+
const fee = String(flags['late-fee']).trim();
|
|
1904
|
+
const m = /^(\d+(?:\.\d{1,2})?)(%?)$/.exec(fee);
|
|
1905
|
+
if (fee === 'none') changes.late_fee_type = 'none';
|
|
1906
|
+
else if (m) Object.assign(changes, { late_fee_type: m[2] ? 'percent' : 'fixed', late_fee_value: Number(m[1]) });
|
|
1907
|
+
else {
|
|
1908
|
+
print.error('--late-fee must be a percent like 1.5%, an amount like 25, or none');
|
|
1909
|
+
process.exit(1);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
if (flags['late-fee-after'] !== undefined) changes.late_fee_after_days = Number(flags['late-fee-after']);
|
|
1913
|
+
if (flags['brand-color'] !== undefined) changes.brand_color = flags['brand-color'] === 'none' ? null : flags['brand-color'];
|
|
1914
|
+
if (flags['logo-url'] !== undefined) changes.logo_url = flags['logo-url'] === 'none' ? null : flags['logo-url'];
|
|
1915
|
+
if (flags.footer !== undefined) changes.invoice_footer = flags.footer === 'none' ? null : flags.footer;
|
|
1916
|
+
|
|
1917
|
+
const settings = Object.keys(changes).length
|
|
1918
|
+
? await updateInvoiceSettings(client, { businessId: flags['business-id'], ...changes })
|
|
1919
|
+
: await getInvoiceSettings(client, { businessId: flags['business-id'] });
|
|
1920
|
+
if (flags.json) {
|
|
1921
|
+
print.json(settings);
|
|
1922
|
+
break;
|
|
1923
|
+
}
|
|
1924
|
+
print.info('Reminders: ' + (settings.reminders_enabled ? settings.reminder_days.join(', ') + ' days from due' : 'off'));
|
|
1925
|
+
print.info(
|
|
1926
|
+
'Late fee: ' +
|
|
1927
|
+
(settings.late_fee_type === 'none'
|
|
1928
|
+
? 'none'
|
|
1929
|
+
: (settings.late_fee_type === 'percent' ? settings.late_fee_value + '%' : settings.late_fee_value) +
|
|
1930
|
+
', ' + settings.late_fee_after_days + ' days after due')
|
|
1931
|
+
);
|
|
1932
|
+
print.info('Brand colour: ' + (settings.brand_color || 'default'));
|
|
1933
|
+
print.info('Logo: ' + (settings.logo_url || 'none'));
|
|
1934
|
+
if (settings.invoice_footer) print.info('Footer: ' + settings.invoice_footer);
|
|
1935
|
+
break;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1514
1938
|
default:
|
|
1515
1939
|
print.error('Unknown invoice command: ' + subcommand);
|
|
1516
|
-
print.info('Available: create, list, get, update, publish, send, delete');
|
|
1940
|
+
print.info('Available: create, list, get, update, publish, send, delete, aging, settings');
|
|
1517
1941
|
process.exit(1);
|
|
1518
1942
|
}
|
|
1519
1943
|
}
|
|
@@ -4589,6 +5013,23 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
4589
5013
|
await handleInvoice(subcommand, args, flags);
|
|
4590
5014
|
break;
|
|
4591
5015
|
|
|
5016
|
+
case 'client':
|
|
5017
|
+
await handleClientCommand(subcommand, args, flags);
|
|
5018
|
+
break;
|
|
5019
|
+
|
|
5020
|
+
case 'project':
|
|
5021
|
+
await handleProject(subcommand, args, flags);
|
|
5022
|
+
break;
|
|
5023
|
+
|
|
5024
|
+
case 'time':
|
|
5025
|
+
await handleTime(subcommand, args, flags);
|
|
5026
|
+
break;
|
|
5027
|
+
|
|
5028
|
+
case 'item':
|
|
5029
|
+
case 'tax-rate':
|
|
5030
|
+
await handleCatalog(command, subcommand, args, flags);
|
|
5031
|
+
break;
|
|
5032
|
+
|
|
4592
5033
|
case 'tokens':
|
|
4593
5034
|
await handleTokens(subcommand, args, flags);
|
|
4594
5035
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/coinpay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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/index.d.ts
CHANGED
|
@@ -55,9 +55,20 @@ export {
|
|
|
55
55
|
sendInvoice,
|
|
56
56
|
getInvoicePaymentData,
|
|
57
57
|
InvoiceStatus,
|
|
58
|
+
listTaxRates,
|
|
59
|
+
createTaxRate,
|
|
60
|
+
deleteTaxRate,
|
|
61
|
+
listInvoiceItems,
|
|
62
|
+
createInvoiceItem,
|
|
63
|
+
deleteInvoiceItem,
|
|
58
64
|
} from './invoices.js';
|
|
59
65
|
export type {
|
|
60
66
|
Invoice,
|
|
67
|
+
InvoiceLine,
|
|
68
|
+
InvoiceLineInput,
|
|
69
|
+
InvoiceDiscount,
|
|
70
|
+
TaxRate,
|
|
71
|
+
InvoiceItem,
|
|
61
72
|
InvoiceStatusType,
|
|
62
73
|
CreateInvoiceParams,
|
|
63
74
|
ListInvoicesParams,
|
|
@@ -65,6 +76,24 @@ export type {
|
|
|
65
76
|
ListInvoicesResult,
|
|
66
77
|
} from './invoices.js';
|
|
67
78
|
|
|
79
|
+
// Projects, time, aging, invoice settings, portal links
|
|
80
|
+
export {
|
|
81
|
+
getArAging,
|
|
82
|
+
getInvoiceSettings,
|
|
83
|
+
updateInvoiceSettings,
|
|
84
|
+
createClientPortalLink,
|
|
85
|
+
revokeClientPortalLink,
|
|
86
|
+
listProjects,
|
|
87
|
+
getProject,
|
|
88
|
+
createProject,
|
|
89
|
+
logTime,
|
|
90
|
+
startTimer,
|
|
91
|
+
stopTimer,
|
|
92
|
+
listTimeEntries,
|
|
93
|
+
invoiceProjectTime,
|
|
94
|
+
} from './projects.js';
|
|
95
|
+
export type { ArAging, AgingRow, InvoiceSettings, Project, ProjectStats, TimeEntry } from './projects.js';
|
|
96
|
+
|
|
68
97
|
// Webhook exports
|
|
69
98
|
export {
|
|
70
99
|
verifyWebhookSignature,
|
package/src/index.js
CHANGED
|
@@ -82,8 +82,31 @@ import {
|
|
|
82
82
|
sendInvoice,
|
|
83
83
|
getInvoicePaymentData,
|
|
84
84
|
InvoiceStatus,
|
|
85
|
+
listTaxRates,
|
|
86
|
+
createTaxRate,
|
|
87
|
+
deleteTaxRate,
|
|
88
|
+
listInvoiceItems,
|
|
89
|
+
createInvoiceItem,
|
|
90
|
+
deleteInvoiceItem,
|
|
85
91
|
} from './invoices.js';
|
|
86
92
|
|
|
93
|
+
// Projects, time, aging, invoice settings, portal links
|
|
94
|
+
import {
|
|
95
|
+
getArAging,
|
|
96
|
+
getInvoiceSettings,
|
|
97
|
+
updateInvoiceSettings,
|
|
98
|
+
createClientPortalLink,
|
|
99
|
+
revokeClientPortalLink,
|
|
100
|
+
listProjects,
|
|
101
|
+
getProject,
|
|
102
|
+
createProject,
|
|
103
|
+
logTime,
|
|
104
|
+
startTimer,
|
|
105
|
+
stopTimer,
|
|
106
|
+
listTimeEntries,
|
|
107
|
+
invoiceProjectTime,
|
|
108
|
+
} from './projects.js';
|
|
109
|
+
|
|
87
110
|
// Wallet exports
|
|
88
111
|
import {
|
|
89
112
|
WalletClient,
|
|
@@ -231,6 +254,25 @@ export {
|
|
|
231
254
|
sendInvoice,
|
|
232
255
|
getInvoicePaymentData,
|
|
233
256
|
InvoiceStatus,
|
|
257
|
+
listTaxRates,
|
|
258
|
+
createTaxRate,
|
|
259
|
+
deleteTaxRate,
|
|
260
|
+
listInvoiceItems,
|
|
261
|
+
createInvoiceItem,
|
|
262
|
+
deleteInvoiceItem,
|
|
263
|
+
getArAging,
|
|
264
|
+
getInvoiceSettings,
|
|
265
|
+
updateInvoiceSettings,
|
|
266
|
+
createClientPortalLink,
|
|
267
|
+
revokeClientPortalLink,
|
|
268
|
+
listProjects,
|
|
269
|
+
getProject,
|
|
270
|
+
createProject,
|
|
271
|
+
logTime,
|
|
272
|
+
startTimer,
|
|
273
|
+
stopTimer,
|
|
274
|
+
listTimeEntries,
|
|
275
|
+
invoiceProjectTime,
|
|
234
276
|
|
|
235
277
|
// Wallet
|
|
236
278
|
WalletClient,
|
package/src/invoices.d.ts
CHANGED
|
@@ -10,6 +10,27 @@ export const InvoiceStatus: {
|
|
|
10
10
|
|
|
11
11
|
export type InvoiceStatusType = (typeof InvoiceStatus)[keyof typeof InvoiceStatus];
|
|
12
12
|
|
|
13
|
+
export interface InvoiceLineInput {
|
|
14
|
+
description: string;
|
|
15
|
+
quantity: number;
|
|
16
|
+
unit_price: number;
|
|
17
|
+
tax_rate?: number | null;
|
|
18
|
+
tax_name?: string | null;
|
|
19
|
+
item_id?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface InvoiceLine extends Required<InvoiceLineInput> {
|
|
23
|
+
/** quantity x unit_price, before discount and tax */
|
|
24
|
+
amount: number;
|
|
25
|
+
discount: number;
|
|
26
|
+
tax: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface InvoiceDiscount {
|
|
30
|
+
type: 'percent' | 'fixed';
|
|
31
|
+
value: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
13
34
|
export interface Invoice {
|
|
14
35
|
id: string;
|
|
15
36
|
invoiceNumber?: string;
|
|
@@ -17,6 +38,11 @@ export interface Invoice {
|
|
|
17
38
|
clientId?: string;
|
|
18
39
|
currency?: string;
|
|
19
40
|
amount?: number;
|
|
41
|
+
lineItems?: InvoiceLine[] | null;
|
|
42
|
+
subtotal?: number | null;
|
|
43
|
+
discount?: InvoiceDiscount | null;
|
|
44
|
+
discountTotal?: number | null;
|
|
45
|
+
taxTotal?: number | null;
|
|
20
46
|
cryptoCurrency?: string;
|
|
21
47
|
cryptoAmount?: string | number;
|
|
22
48
|
status?: InvoiceStatusType;
|
|
@@ -36,7 +62,10 @@ export interface CreateInvoiceParams {
|
|
|
36
62
|
businessId?: string;
|
|
37
63
|
clientId?: string;
|
|
38
64
|
currency: string;
|
|
39
|
-
|
|
65
|
+
/** Required unless lineItems is given; the server computes itemised totals. */
|
|
66
|
+
amount?: number;
|
|
67
|
+
lineItems?: InvoiceLineInput[];
|
|
68
|
+
discount?: InvoiceDiscount;
|
|
40
69
|
cryptoCurrency?: string;
|
|
41
70
|
dueDate?: string;
|
|
42
71
|
notes?: string;
|
|
@@ -54,6 +83,9 @@ export interface ListInvoicesParams {
|
|
|
54
83
|
|
|
55
84
|
export interface UpdateInvoiceParams {
|
|
56
85
|
amount?: number;
|
|
86
|
+
/** Replace the lines; null turns itemising off. */
|
|
87
|
+
lineItems?: InvoiceLineInput[] | null;
|
|
88
|
+
discount?: InvoiceDiscount | null;
|
|
57
89
|
currency?: string;
|
|
58
90
|
cryptoCurrency?: string;
|
|
59
91
|
dueDate?: string;
|
|
@@ -76,3 +108,42 @@ export function deleteInvoice(client: CoinPayClient, id: string): Promise<Record
|
|
|
76
108
|
export function publishInvoice(client: CoinPayClient, id: string): Promise<Record<string, unknown>>;
|
|
77
109
|
export function sendInvoice(client: CoinPayClient, id: string): Promise<Record<string, unknown>>;
|
|
78
110
|
export function getInvoicePaymentData(client: CoinPayClient, id: string): Promise<Record<string, unknown>>;
|
|
111
|
+
|
|
112
|
+
export interface TaxRate {
|
|
113
|
+
id: string;
|
|
114
|
+
business_id: string;
|
|
115
|
+
name: string;
|
|
116
|
+
rate: number;
|
|
117
|
+
is_default: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface InvoiceItem {
|
|
121
|
+
id: string;
|
|
122
|
+
business_id: string;
|
|
123
|
+
name: string;
|
|
124
|
+
description: string | null;
|
|
125
|
+
unit_price: number;
|
|
126
|
+
unit: string | null;
|
|
127
|
+
tax_rate_id: string | null;
|
|
128
|
+
archived: boolean;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function listTaxRates(client: CoinPayClient, options?: { businessId?: string }): Promise<TaxRate[]>;
|
|
132
|
+
export function createTaxRate(
|
|
133
|
+
client: CoinPayClient,
|
|
134
|
+
options: { businessId?: string; name: string; rate: number; isDefault?: boolean }
|
|
135
|
+
): Promise<TaxRate>;
|
|
136
|
+
export function deleteTaxRate(client: CoinPayClient, id: string): Promise<Record<string, unknown>>;
|
|
137
|
+
export function listInvoiceItems(client: CoinPayClient, options?: { businessId?: string }): Promise<InvoiceItem[]>;
|
|
138
|
+
export function createInvoiceItem(
|
|
139
|
+
client: CoinPayClient,
|
|
140
|
+
options: {
|
|
141
|
+
businessId?: string;
|
|
142
|
+
name: string;
|
|
143
|
+
unitPrice: number;
|
|
144
|
+
description?: string;
|
|
145
|
+
unit?: string;
|
|
146
|
+
taxRateId?: string;
|
|
147
|
+
}
|
|
148
|
+
): Promise<InvoiceItem>;
|
|
149
|
+
export function deleteInvoiceItem(client: CoinPayClient, id: string): Promise<Record<string, unknown>>;
|
package/src/invoices.js
CHANGED
|
@@ -35,7 +35,10 @@
|
|
|
35
35
|
* @param {string} [options.businessId] - Business ID (optional for business-scoped API keys)
|
|
36
36
|
* @param {string} [options.clientId] - Client ID (optional)
|
|
37
37
|
* @param {string} options.currency - Fiat currency code (USD, EUR, etc.)
|
|
38
|
-
* @param {number} options.amount - Invoice amount
|
|
38
|
+
* @param {number} [options.amount] - Invoice amount (omit when lineItems is given)
|
|
39
|
+
* @param {Array<{description: string, quantity: number, unit_price: number, tax_rate?: number, tax_name?: string, item_id?: string}>} [options.lineItems]
|
|
40
|
+
* Itemised lines; the server computes the total from them
|
|
41
|
+
* @param {{type: 'percent'|'fixed', value: number}} [options.discount] - Invoice-level discount, applied before tax
|
|
39
42
|
* @param {string} [options.cryptoCurrency] - Preferred crypto for payment
|
|
40
43
|
* @param {string} [options.dueDate] - Due date (ISO string or YYYY-MM-DD)
|
|
41
44
|
* @param {string} [options.notes] - Notes / description
|
|
@@ -48,6 +51,8 @@ export async function createInvoice(client, {
|
|
|
48
51
|
clientId,
|
|
49
52
|
currency,
|
|
50
53
|
amount,
|
|
54
|
+
lineItems,
|
|
55
|
+
discount,
|
|
51
56
|
cryptoCurrency,
|
|
52
57
|
dueDate,
|
|
53
58
|
notes,
|
|
@@ -57,8 +62,13 @@ export async function createInvoice(client, {
|
|
|
57
62
|
const body = {
|
|
58
63
|
business_id: businessId,
|
|
59
64
|
currency,
|
|
60
|
-
amount,
|
|
61
65
|
};
|
|
66
|
+
if (lineItems) {
|
|
67
|
+
body.line_items = lineItems;
|
|
68
|
+
if (discount) body.discount = discount;
|
|
69
|
+
} else {
|
|
70
|
+
body.amount = amount;
|
|
71
|
+
}
|
|
62
72
|
if (clientId) body.client_id = clientId;
|
|
63
73
|
if (cryptoCurrency) body.crypto_currency = cryptoCurrency;
|
|
64
74
|
if (dueDate) body.due_date = dueDate;
|
|
@@ -124,7 +134,9 @@ export async function getInvoice(client, id) {
|
|
|
124
134
|
* @param {CoinPayClient} client
|
|
125
135
|
* @param {string} id - Invoice ID
|
|
126
136
|
* @param {Object} updates - Partial invoice fields to update
|
|
127
|
-
* @param {number} [updates.amount] - Updated amount
|
|
137
|
+
* @param {number} [updates.amount] - Updated amount (single-amount invoices only)
|
|
138
|
+
* @param {Array|null} [updates.lineItems] - Replace the lines (null turns itemising off)
|
|
139
|
+
* @param {Object|null} [updates.discount] - Replace the discount
|
|
128
140
|
* @param {string} [updates.currency] - Updated currency
|
|
129
141
|
* @param {string} [updates.cryptoCurrency] - Updated crypto currency
|
|
130
142
|
* @param {string} [updates.dueDate] - Updated due date
|
|
@@ -137,6 +149,8 @@ export async function getInvoice(client, id) {
|
|
|
137
149
|
export async function updateInvoice(client, id, updates) {
|
|
138
150
|
const body = {};
|
|
139
151
|
if (updates.amount !== undefined) body.amount = updates.amount;
|
|
152
|
+
if (updates.lineItems !== undefined) body.line_items = updates.lineItems;
|
|
153
|
+
if (updates.discount !== undefined) body.discount = updates.discount;
|
|
140
154
|
if (updates.currency !== undefined) body.currency = updates.currency;
|
|
141
155
|
if (updates.cryptoCurrency !== undefined) body.crypto_currency = updates.cryptoCurrency;
|
|
142
156
|
if (updates.dueDate !== undefined) body.due_date = updates.dueDate;
|
|
@@ -233,6 +247,11 @@ function normalizeInvoice(data) {
|
|
|
233
247
|
clientId: invoice.client_id,
|
|
234
248
|
currency: invoice.currency,
|
|
235
249
|
amount: invoice.amount,
|
|
250
|
+
lineItems: invoice.line_items,
|
|
251
|
+
subtotal: invoice.subtotal,
|
|
252
|
+
discount: invoice.discount,
|
|
253
|
+
discountTotal: invoice.discount_total,
|
|
254
|
+
taxTotal: invoice.tax_total,
|
|
236
255
|
cryptoCurrency: invoice.crypto_currency,
|
|
237
256
|
cryptoAmount: invoice.crypto_amount,
|
|
238
257
|
status: invoice.status,
|
|
@@ -248,3 +267,52 @@ function normalizeInvoice(data) {
|
|
|
248
267
|
updatedAt: invoice.updated_at,
|
|
249
268
|
};
|
|
250
269
|
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Catalog: saved tax rates and products/services for itemised invoices.
|
|
273
|
+
// An API key is locked to its own business, so businessId is optional there.
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
function businessQuery(businessId) {
|
|
277
|
+
return businessId ? '?business_id=' + encodeURIComponent(businessId) : '';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** List a business's saved tax rates. */
|
|
281
|
+
export async function listTaxRates(client, { businessId } = {}) {
|
|
282
|
+
const data = await client.request('/tax-rates' + businessQuery(businessId));
|
|
283
|
+
return data.tax_rates ?? [];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Save a tax rate (percent, up to three decimals). */
|
|
287
|
+
export async function createTaxRate(client, { businessId, name, rate, isDefault }) {
|
|
288
|
+
const body = { name, rate };
|
|
289
|
+
if (businessId) body.business_id = businessId;
|
|
290
|
+
if (isDefault !== undefined) body.is_default = isDefault;
|
|
291
|
+
const data = await client.request('/tax-rates', { method: 'POST', body: JSON.stringify(body) });
|
|
292
|
+
return data.tax_rate;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function deleteTaxRate(client, id) {
|
|
296
|
+
return client.request('/tax-rates/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** List a business's saved products and services. */
|
|
300
|
+
export async function listInvoiceItems(client, { businessId } = {}) {
|
|
301
|
+
const data = await client.request('/invoice-items' + businessQuery(businessId));
|
|
302
|
+
return data.invoice_items ?? [];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Save a product or service to bill again later. */
|
|
306
|
+
export async function createInvoiceItem(client, { businessId, name, unitPrice, description, unit, taxRateId }) {
|
|
307
|
+
const body = { name, unit_price: unitPrice };
|
|
308
|
+
if (businessId) body.business_id = businessId;
|
|
309
|
+
if (description) body.description = description;
|
|
310
|
+
if (unit) body.unit = unit;
|
|
311
|
+
if (taxRateId) body.tax_rate_id = taxRateId;
|
|
312
|
+
const data = await client.request('/invoice-items', { method: 'POST', body: JSON.stringify(body) });
|
|
313
|
+
return data.invoice_item;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function deleteInvoiceItem(client, id) {
|
|
317
|
+
return client.request('/invoice-items/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
318
|
+
}
|
package/src/mcp.js
CHANGED
|
@@ -15,6 +15,8 @@ import { createInterface } from 'node:readline';
|
|
|
15
15
|
import * as reports from './finances-reports.js';
|
|
16
16
|
import { getFinanceSummary, listFinanceAccounts, listFinanceTransactions } from './finances.js';
|
|
17
17
|
import * as sf from './statements-fetch.js';
|
|
18
|
+
import { listInvoices, getInvoice, createInvoice } from './invoices.js';
|
|
19
|
+
import * as projects from './projects.js';
|
|
18
20
|
|
|
19
21
|
export const PROTOCOL_VERSION = '2025-06-18';
|
|
20
22
|
|
|
@@ -218,6 +220,107 @@ export const TOOLS = [
|
|
|
218
220
|
return { ...text({ banks: results }), isError: false };
|
|
219
221
|
},
|
|
220
222
|
},
|
|
223
|
+
{
|
|
224
|
+
name: 'invoice_list',
|
|
225
|
+
description: 'List invoices (number, status, amount, currency, due date, client). Filter by status: draft, sent, overdue, paid, cancelled.',
|
|
226
|
+
inputSchema: {
|
|
227
|
+
type: 'object',
|
|
228
|
+
properties: {
|
|
229
|
+
businessId: { type: 'string' },
|
|
230
|
+
status: { type: 'string', enum: ['draft', 'sent', 'overdue', 'paid', 'cancelled'] },
|
|
231
|
+
clientId: { type: 'string' },
|
|
232
|
+
},
|
|
233
|
+
additionalProperties: false,
|
|
234
|
+
},
|
|
235
|
+
handler: async (args, { client }) => text(await listInvoices(client, args)),
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: 'invoice_get',
|
|
239
|
+
description: 'One invoice with its line items, totals, payment details and status.',
|
|
240
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'], additionalProperties: false },
|
|
241
|
+
handler: async (args, { client }) => text(await getInvoice(client, args.id)),
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: 'invoice_create_draft',
|
|
245
|
+
description:
|
|
246
|
+
'Create a DRAFT itemised invoice (nothing is sent). lineItems: [{description, quantity, unit_price, tax_rate?, tax_name?}]; discount: {type: percent|fixed, value}. The server computes the total.',
|
|
247
|
+
inputSchema: {
|
|
248
|
+
type: 'object',
|
|
249
|
+
properties: {
|
|
250
|
+
businessId: { type: 'string' },
|
|
251
|
+
clientId: { type: 'string' },
|
|
252
|
+
currency: { type: 'string', description: 'ISO code, default USD' },
|
|
253
|
+
dueDate: { type: 'string', description: 'ISO date' },
|
|
254
|
+
notes: { type: 'string' },
|
|
255
|
+
lineItems: {
|
|
256
|
+
type: 'array',
|
|
257
|
+
minItems: 1,
|
|
258
|
+
maxItems: 100,
|
|
259
|
+
items: {
|
|
260
|
+
type: 'object',
|
|
261
|
+
properties: {
|
|
262
|
+
description: { type: 'string' },
|
|
263
|
+
quantity: { type: 'number' },
|
|
264
|
+
unit_price: { type: 'number' },
|
|
265
|
+
tax_rate: { type: 'number' },
|
|
266
|
+
tax_name: { type: 'string' },
|
|
267
|
+
},
|
|
268
|
+
required: ['description', 'quantity', 'unit_price'],
|
|
269
|
+
additionalProperties: false,
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
discount: {
|
|
273
|
+
type: 'object',
|
|
274
|
+
properties: { type: { type: 'string', enum: ['percent', 'fixed'] }, value: { type: 'number' } },
|
|
275
|
+
required: ['type', 'value'],
|
|
276
|
+
additionalProperties: false,
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
required: ['lineItems'],
|
|
280
|
+
additionalProperties: false,
|
|
281
|
+
},
|
|
282
|
+
handler: async (args, { client }) => text(await createInvoice(client, { ...args, currency: args.currency || 'USD' })),
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
name: 'invoice_ar_aging',
|
|
286
|
+
description: 'Accounts receivable aging: unpaid sent invoices by days past due (current, 1-30, 31-60, 61-90, 90+), per currency and per client.',
|
|
287
|
+
inputSchema: { type: 'object', properties: { businessId: { type: 'string' } }, additionalProperties: false },
|
|
288
|
+
handler: async (args, { client }) => text(await projects.getArAging(client, args)),
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
name: 'project_list',
|
|
292
|
+
description: 'List active projects (name, client, hourly rate, budget).',
|
|
293
|
+
inputSchema: { type: 'object', properties: { businessId: { type: 'string' }, includeArchived: { type: 'boolean' } }, additionalProperties: false },
|
|
294
|
+
handler: async (args, { client }) => text({ projects: await projects.listProjects(client, args) }),
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
name: 'project_get',
|
|
298
|
+
description: 'One project with hours logged, unbilled value, billed and paid totals.',
|
|
299
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'], additionalProperties: false },
|
|
300
|
+
handler: async (args, { client }) => text(await projects.getProject(client, args.id)),
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: 'time_log',
|
|
304
|
+
description: 'Log finished time on a project, in minutes.',
|
|
305
|
+
inputSchema: {
|
|
306
|
+
type: 'object',
|
|
307
|
+
properties: {
|
|
308
|
+
projectId: { type: 'string' },
|
|
309
|
+
minutes: { type: 'integer', minimum: 1, maximum: 100000 },
|
|
310
|
+
description: { type: 'string' },
|
|
311
|
+
billable: { type: 'boolean' },
|
|
312
|
+
},
|
|
313
|
+
required: ['projectId', 'minutes'],
|
|
314
|
+
additionalProperties: false,
|
|
315
|
+
},
|
|
316
|
+
handler: async (args, { client }) => text({ time_entry: await projects.logTime(client, args) }),
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: 'project_invoice_time',
|
|
320
|
+
description: "Bill a project's unbilled billable time as a DRAFT itemised invoice (not sent). Returns the invoice number and amount.",
|
|
321
|
+
inputSchema: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'], additionalProperties: false },
|
|
322
|
+
handler: async (args, { client }) => text(await projects.invoiceProjectTime(client, args.projectId)),
|
|
323
|
+
},
|
|
221
324
|
];
|
|
222
325
|
|
|
223
326
|
/** Answer one JSON-RPC message; null for a notification. */
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { CoinPayClient } from './client.js';
|
|
2
|
+
|
|
3
|
+
export interface AgingRow {
|
|
4
|
+
currency: string;
|
|
5
|
+
current: number;
|
|
6
|
+
'1_30': number;
|
|
7
|
+
'31_60': number;
|
|
8
|
+
'61_90': number;
|
|
9
|
+
'90_plus': number;
|
|
10
|
+
total: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ArAging {
|
|
14
|
+
as_of: string;
|
|
15
|
+
totals: AgingRow[];
|
|
16
|
+
clients: Array<AgingRow & { client_id: string | null; name: string; invoices: number }>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface InvoiceSettings {
|
|
20
|
+
business_id: string;
|
|
21
|
+
reminders_enabled: boolean;
|
|
22
|
+
reminder_days: number[];
|
|
23
|
+
late_fee_type: 'none' | 'percent' | 'fixed';
|
|
24
|
+
late_fee_value: number | null;
|
|
25
|
+
late_fee_after_days: number;
|
|
26
|
+
brand_color: string | null;
|
|
27
|
+
logo_url: string | null;
|
|
28
|
+
invoice_footer: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Project {
|
|
32
|
+
id: string;
|
|
33
|
+
business_id: string;
|
|
34
|
+
client_id: string | null;
|
|
35
|
+
name: string;
|
|
36
|
+
hourly_rate: number | null;
|
|
37
|
+
currency: string;
|
|
38
|
+
budget_hours: number | null;
|
|
39
|
+
status: 'active' | 'archived';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ProjectStats {
|
|
43
|
+
hours: number;
|
|
44
|
+
billable_hours: number;
|
|
45
|
+
unbilled_amount: number;
|
|
46
|
+
billed_amount: number;
|
|
47
|
+
paid_amount: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TimeEntry {
|
|
51
|
+
id: string;
|
|
52
|
+
project_id: string;
|
|
53
|
+
description: string;
|
|
54
|
+
started_at: string;
|
|
55
|
+
ended_at: string | null;
|
|
56
|
+
/** null while the timer is running */
|
|
57
|
+
minutes: number | null;
|
|
58
|
+
billable: boolean;
|
|
59
|
+
hourly_rate: number | null;
|
|
60
|
+
invoice_id: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getArAging(client: CoinPayClient, options?: { businessId?: string }): Promise<ArAging>;
|
|
64
|
+
export function getInvoiceSettings(client: CoinPayClient, options?: { businessId?: string }): Promise<InvoiceSettings>;
|
|
65
|
+
export function updateInvoiceSettings(
|
|
66
|
+
client: CoinPayClient,
|
|
67
|
+
options: { businessId?: string } & Partial<Omit<InvoiceSettings, 'business_id'>>
|
|
68
|
+
): Promise<InvoiceSettings>;
|
|
69
|
+
export function createClientPortalLink(client: CoinPayClient, clientId: string): Promise<string>;
|
|
70
|
+
export function revokeClientPortalLink(client: CoinPayClient, clientId: string): Promise<Record<string, unknown>>;
|
|
71
|
+
export function listProjects(client: CoinPayClient, options?: { businessId?: string; includeArchived?: boolean }): Promise<Project[]>;
|
|
72
|
+
export function getProject(client: CoinPayClient, id: string): Promise<{ project: Project; stats: ProjectStats }>;
|
|
73
|
+
export function createProject(
|
|
74
|
+
client: CoinPayClient,
|
|
75
|
+
options: { businessId?: string; name: string; clientId?: string; hourlyRate?: number; currency?: string; budgetHours?: number; notes?: string }
|
|
76
|
+
): Promise<Project>;
|
|
77
|
+
export function logTime(
|
|
78
|
+
client: CoinPayClient,
|
|
79
|
+
options: { projectId: string; minutes: number; description?: string; billable?: boolean; hourlyRate?: number; startedAt?: string }
|
|
80
|
+
): Promise<TimeEntry>;
|
|
81
|
+
export function startTimer(client: CoinPayClient, options: { projectId: string; description?: string }): Promise<TimeEntry>;
|
|
82
|
+
export function stopTimer(client: CoinPayClient, options?: { entryId?: string; businessId?: string }): Promise<TimeEntry>;
|
|
83
|
+
export function listTimeEntries(
|
|
84
|
+
client: CoinPayClient,
|
|
85
|
+
options?: { projectId?: string; businessId?: string; unbilled?: boolean }
|
|
86
|
+
): Promise<TimeEntry[]>;
|
|
87
|
+
export function invoiceProjectTime(
|
|
88
|
+
client: CoinPayClient,
|
|
89
|
+
projectId: string,
|
|
90
|
+
options?: { entryIds?: string[] }
|
|
91
|
+
): Promise<{ invoice: { id: string; invoice_number: string; amount: number }; entries_billed: number }>;
|
package/src/projects.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects, time tracking, AR aging, invoice settings and client portal links.
|
|
3
|
+
*
|
|
4
|
+
* An API key is locked to its own business, so `businessId` is optional when
|
|
5
|
+
* the client was created with one.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const q = (params) => {
|
|
9
|
+
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '');
|
|
10
|
+
return entries.length ? '?' + new URLSearchParams(entries.map(([k, v]) => [k, String(v)])).toString() : '';
|
|
11
|
+
};
|
|
12
|
+
const json = (method, body) => ({ method, body: JSON.stringify(body) });
|
|
13
|
+
|
|
14
|
+
/** Open invoices by days past due, per currency and per client. */
|
|
15
|
+
export async function getArAging(client, { businessId } = {}) {
|
|
16
|
+
return client.request('/invoices/aging' + q({ business_id: businessId }));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Reminder schedule, late fee and branding. */
|
|
20
|
+
export async function getInvoiceSettings(client, { businessId } = {}) {
|
|
21
|
+
const data = await client.request('/invoice-settings' + q({ business_id: businessId }));
|
|
22
|
+
return data.settings;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Update invoice settings. Fields use the API's names: reminders_enabled,
|
|
27
|
+
* reminder_days (e.g. [-3, 0, 7]), late_fee_type ('none'|'percent'|'fixed'),
|
|
28
|
+
* late_fee_value, late_fee_after_days, brand_color, logo_url, invoice_footer.
|
|
29
|
+
*/
|
|
30
|
+
export async function updateInvoiceSettings(client, { businessId, ...fields }) {
|
|
31
|
+
const data = await client.request('/invoice-settings', json('PUT', { business_id: businessId, ...fields }));
|
|
32
|
+
return data.settings;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Create (or replace) a client's portal link; the old one stops working. */
|
|
36
|
+
export async function createClientPortalLink(client, clientId) {
|
|
37
|
+
const data = await client.request(`/clients/${encodeURIComponent(clientId)}/portal-link`, { method: 'POST' });
|
|
38
|
+
return data.url;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function revokeClientPortalLink(client, clientId) {
|
|
42
|
+
return client.request(`/clients/${encodeURIComponent(clientId)}/portal-link`, { method: 'DELETE' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function listProjects(client, { businessId, includeArchived } = {}) {
|
|
46
|
+
const data = await client.request('/projects' + q({ business_id: businessId, include_archived: includeArchived ? 'true' : undefined }));
|
|
47
|
+
return data.projects ?? [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A project with its hours, unbilled, billed and paid totals. */
|
|
51
|
+
export async function getProject(client, id) {
|
|
52
|
+
return client.request('/projects/' + encodeURIComponent(id));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function createProject(client, { businessId, name, clientId, hourlyRate, currency, budgetHours, notes }) {
|
|
56
|
+
const body = { name };
|
|
57
|
+
if (businessId) body.business_id = businessId;
|
|
58
|
+
if (clientId) body.client_id = clientId;
|
|
59
|
+
if (hourlyRate !== undefined) body.hourly_rate = hourlyRate;
|
|
60
|
+
if (currency) body.currency = currency;
|
|
61
|
+
if (budgetHours !== undefined) body.budget_hours = budgetHours;
|
|
62
|
+
if (notes) body.notes = notes;
|
|
63
|
+
const data = await client.request('/projects', json('POST', body));
|
|
64
|
+
return data.project;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Log finished time on a project. */
|
|
68
|
+
export async function logTime(client, { projectId, minutes, description, billable, hourlyRate, startedAt }) {
|
|
69
|
+
const body = { project_id: projectId, minutes };
|
|
70
|
+
if (description) body.description = description;
|
|
71
|
+
if (billable !== undefined) body.billable = billable;
|
|
72
|
+
if (hourlyRate !== undefined) body.hourly_rate = hourlyRate;
|
|
73
|
+
if (startedAt) body.started_at = startedAt;
|
|
74
|
+
const data = await client.request('/time-entries', json('POST', body));
|
|
75
|
+
return data.time_entry;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Start a running timer (one per person). */
|
|
79
|
+
export async function startTimer(client, { projectId, description }) {
|
|
80
|
+
const data = await client.request('/time-entries', json('POST', { project_id: projectId, start: true, ...(description ? { description } : {}) }));
|
|
81
|
+
return data.time_entry;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Stop a running timer; with no id, stops yours wherever it runs. */
|
|
85
|
+
export async function stopTimer(client, { entryId, businessId } = {}) {
|
|
86
|
+
let id = entryId;
|
|
87
|
+
if (!id) {
|
|
88
|
+
const data = await client.request('/time-entries' + q({ business_id: businessId, running: 'true' }));
|
|
89
|
+
id = data.time_entries?.[0]?.id;
|
|
90
|
+
if (!id) throw new Error('No timer is running');
|
|
91
|
+
}
|
|
92
|
+
const data = await client.request('/time-entries/' + encodeURIComponent(id), json('PATCH', { stop: true }));
|
|
93
|
+
return data.time_entry;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function listTimeEntries(client, { projectId, businessId, unbilled } = {}) {
|
|
97
|
+
const data = await client.request(
|
|
98
|
+
'/time-entries' + q({ project_id: projectId, business_id: businessId, unbilled: unbilled ? 'true' : undefined })
|
|
99
|
+
);
|
|
100
|
+
return data.time_entries ?? [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Bill a project's unbilled billable time as a draft itemised invoice. */
|
|
104
|
+
export async function invoiceProjectTime(client, projectId, { entryIds } = {}) {
|
|
105
|
+
return client.request(`/projects/${encodeURIComponent(projectId)}/invoice`, json('POST', entryIds ? { entry_ids: entryIds } : {}));
|
|
106
|
+
}
|
package/src/x402.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* x402 Payment Protocol Support
|
|
3
3
|
*
|
|
4
4
|
* CoinPayPortal as the first multi-chain, multi-asset x402 facilitator.
|
|
5
|
-
* Supports native crypto (BTC, ETH, SOL, POL), USDC stablecoins,
|
|
5
|
+
* Supports native crypto (BTC, ETH, SOL, POL, XNO), USDC stablecoins,
|
|
6
6
|
* Lightning (BOLT12), and Stripe fiat — all via HTTP 402.
|
|
7
7
|
*
|
|
8
8
|
* Note: Bitcoin Cash is intentionally not listed. The x402 settle path has no
|
|
@@ -92,6 +92,18 @@ export const PAYMENT_METHODS = {
|
|
|
92
92
|
label: 'USDC on Base',
|
|
93
93
|
},
|
|
94
94
|
|
|
95
|
+
// ── Nano ───────────────────────────────────────────────────────
|
|
96
|
+
// Opt-in: only advertised when `payTo` names a Nano address (`payTo.nano`
|
|
97
|
+
// or a `nano_` string) and an XNO amount can be quoted (`rates.XNO`, or a
|
|
98
|
+
// raw `amount`). The payer sends a block; the proof carries its `blockHash`.
|
|
99
|
+
xno: {
|
|
100
|
+
network: 'nano',
|
|
101
|
+
asset: 'XNO',
|
|
102
|
+
scheme: 'exact',
|
|
103
|
+
decimals: 30,
|
|
104
|
+
label: 'Nano (XNO)',
|
|
105
|
+
},
|
|
106
|
+
|
|
95
107
|
// ── Lightning ──────────────────────────────────────────────────
|
|
96
108
|
lightning: {
|
|
97
109
|
network: 'lightning',
|
|
@@ -175,6 +187,14 @@ export function convertUsdToAssetAmount(amountUsd, methodKey, rates = {}) {
|
|
|
175
187
|
if (!rate) throw new Error(`No exchange rate for ${method.asset}`);
|
|
176
188
|
|
|
177
189
|
const assetAmount = amountUsd / rate;
|
|
190
|
+
|
|
191
|
+
if (method.network === 'nano') {
|
|
192
|
+
// 30 decimals is past float64's exact range, and String() of a number that
|
|
193
|
+
// large is exponent notation ("1e+27"), which no verifier parses. Quote to
|
|
194
|
+
// the nearest 10^-9 XNO and scale the rest in BigInt.
|
|
195
|
+
return (BigInt(Math.round(assetAmount * 1e9)) * 10n ** 21n).toString();
|
|
196
|
+
}
|
|
197
|
+
|
|
178
198
|
const smallest = Math.round(assetAmount * Math.pow(10, method.decimals));
|
|
179
199
|
return String(smallest);
|
|
180
200
|
}
|
|
@@ -288,6 +308,8 @@ export function buildPaymentRequired(options) {
|
|
|
288
308
|
addr = payTo;
|
|
289
309
|
}
|
|
290
310
|
if (!addr) continue; // skip methods where merchant has no address
|
|
311
|
+
// A single non-Nano address must not be advertised as a Nano payee.
|
|
312
|
+
if (method.network === 'nano' && !/^(nano|xrb)_/.test(addr)) continue;
|
|
291
313
|
|
|
292
314
|
// Convert USD to asset amount
|
|
293
315
|
let assetAmount;
|
|
@@ -329,6 +351,7 @@ function _networkToMethodKey(network) {
|
|
|
329
351
|
polygon: 'pol',
|
|
330
352
|
solana: 'sol',
|
|
331
353
|
bitcoin: 'btc',
|
|
354
|
+
nano: 'xno',
|
|
332
355
|
lightning: 'lightning',
|
|
333
356
|
stripe: 'stripe',
|
|
334
357
|
};
|