@profullstack/coinpay 0.17.0 → 0.19.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 +40 -0
- package/bin/coinpay.js +509 -2
- package/package.json +1 -1
- package/src/contractors.d.ts +124 -0
- package/src/contractors.js +135 -0
- package/src/index.d.ts +39 -0
- package/src/index.js +69 -0
- package/src/mcp.js +166 -0
- package/src/projects.d.ts +91 -0
- package/src/projects.js +106 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,46 @@ 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.19.0] - 2026-10-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Contractors: `coinpay contractor list|add|get|link|offboard|contract|send|end|pending|approve|reject|bonus|expense|1099`.
|
|
12
|
+
Invite a contractor (they get a private portal to add a payout wallet, file a W-9 or W-8,
|
|
13
|
+
sign contracts and submit hours, milestones and expenses), contract fixed, hourly or
|
|
14
|
+
milestone work, approve submissions, and see who needs a 1099-NEC.
|
|
15
|
+
- Pay runs: `coinpay payrun list|create|get|pay|record|delete`. A run collects fixed fees
|
|
16
|
+
that are due plus approved work, one line per contractor. `pay <id> --yes` sends from the
|
|
17
|
+
business payout wallet (signed-in owner only); `record` logs a payment made from your own wallet.
|
|
18
|
+
- SDK: `listContractors`, `addContractor`, `createContract`, `sendContract`, `reviewWorkItem`,
|
|
19
|
+
`createPayRun`, `payPayRun`, `recordPayRunPayments`, `get1099Summary` and friends.
|
|
20
|
+
- MCP: `contractor_list`, `contractor_get`, `contractor_work_list`, `contractor_work_review`,
|
|
21
|
+
`pay_run_list`, `pay_run_get`, `pay_run_draft`, `contractor_1099_summary`. None of them sends money.
|
|
22
|
+
|
|
23
|
+
## [0.18.0] - 2026-10-17
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- Projects and time tracking: `coinpay project list|create|get|invoice`,
|
|
27
|
+
`coinpay time log|start|stop|list`. `project invoice` bills unbilled
|
|
28
|
+
billable time as a draft itemised invoice; billed time is locked.
|
|
29
|
+
- `coinpay invoice aging`: unpaid sent invoices by days past due, per
|
|
30
|
+
currency and per client.
|
|
31
|
+
- `coinpay invoice settings`: reminder schedule (`--reminders "-3,0,7"`),
|
|
32
|
+
late fee (`--late-fee 1.5%` or `25`, `--late-fee-after 3`) and branding
|
|
33
|
+
(`--brand-color`, `--logo-url`, `--footer`).
|
|
34
|
+
- `coinpay client portal-link <id>`: one link where a client sees and pays
|
|
35
|
+
all their invoices; `--revoke` turns it off.
|
|
36
|
+
- MCP: `invoice_list`, `invoice_get`, `invoice_create_draft`,
|
|
37
|
+
`invoice_ar_aging`, `project_list`, `project_get`, `time_log`,
|
|
38
|
+
`project_invoice_time`.
|
|
39
|
+
- SDK: `getArAging`, `getInvoiceSettings`, `updateInvoiceSettings`,
|
|
40
|
+
`createClientPortalLink`, `revokeClientPortalLink`, `listProjects`,
|
|
41
|
+
`getProject`, `createProject`, `logTime`, `startTimer`, `stopTimer`,
|
|
42
|
+
`listTimeEntries`, `invoiceProjectTime`.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
- A flag value that starts with a minus and a digit (`--reminders -3,0,7`)
|
|
46
|
+
is read as the value instead of as another flag.
|
|
47
|
+
|
|
8
48
|
## [0.17.0] - 2026-10-17
|
|
9
49
|
|
|
10
50
|
### Added
|
package/bin/coinpay.js
CHANGED
|
@@ -31,6 +31,41 @@ import {
|
|
|
31
31
|
createInvoiceItem,
|
|
32
32
|
deleteInvoiceItem,
|
|
33
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';
|
|
49
|
+
import {
|
|
50
|
+
listContractors,
|
|
51
|
+
getContractor,
|
|
52
|
+
addContractor,
|
|
53
|
+
createContractorPortalLink,
|
|
54
|
+
offboardContractor,
|
|
55
|
+
createContract,
|
|
56
|
+
sendContract,
|
|
57
|
+
endContract,
|
|
58
|
+
listWorkItems,
|
|
59
|
+
reviewWorkItem,
|
|
60
|
+
addContractorPayment,
|
|
61
|
+
listPayRuns,
|
|
62
|
+
getPayRun,
|
|
63
|
+
createPayRun,
|
|
64
|
+
deletePayRun,
|
|
65
|
+
payPayRun,
|
|
66
|
+
recordPayRunPayments,
|
|
67
|
+
get1099Summary,
|
|
68
|
+
} from '../src/contractors.js';
|
|
34
69
|
import { readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from 'fs';
|
|
35
70
|
import { execSync, spawn } from 'child_process';
|
|
36
71
|
import { createInterface } from 'readline';
|
|
@@ -173,6 +208,11 @@ function createUnauthenticatedClient() {
|
|
|
173
208
|
}
|
|
174
209
|
|
|
175
210
|
const BOOLEAN_FLAGS = new Set([
|
|
211
|
+
// projects, time, client portal
|
|
212
|
+
'non-billable',
|
|
213
|
+
'unbilled',
|
|
214
|
+
'archived',
|
|
215
|
+
'revoke',
|
|
176
216
|
'active',
|
|
177
217
|
// finances reports / statements
|
|
178
218
|
'wait',
|
|
@@ -237,7 +277,7 @@ function parseArgs(args) {
|
|
|
237
277
|
const next = args[i + 1];
|
|
238
278
|
if (BOOLEAN_FLAGS.has(name)) {
|
|
239
279
|
result.flags[name] = true;
|
|
240
|
-
} else if (next && !next.startsWith('-')) {
|
|
280
|
+
} else if (next && (!next.startsWith('-') || /^-\d/.test(next))) {
|
|
241
281
|
setFlag(name, next);
|
|
242
282
|
i++;
|
|
243
283
|
} else {
|
|
@@ -471,6 +511,49 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
471
511
|
delete <id> Permanently delete a draft invoice
|
|
472
512
|
--yes Skip confirmation for publish/send/delete
|
|
473
513
|
|
|
514
|
+
${colors.bright}invoice aging${colors.reset} Unpaid invoices by days past due (--business-id)
|
|
515
|
+
${colors.bright}invoice settings${colors.reset} Show reminders, late fee, branding; change with
|
|
516
|
+
--reminders "-3,0,7" | off, --late-fee 1.5% | 25 | none,
|
|
517
|
+
--late-fee-after 3, --brand-color "#7c3aed", --logo-url, --footer
|
|
518
|
+
|
|
519
|
+
${colors.bright}client${colors.reset}
|
|
520
|
+
portal-link <client-id> Create a portal link listing all their invoices
|
|
521
|
+
(replaces the old link; --revoke to turn it off)
|
|
522
|
+
|
|
523
|
+
${colors.bright}contractor${colors.reset}
|
|
524
|
+
list Contractors (--business-id, --all includes offboarded)
|
|
525
|
+
add --name, --email [--country US] [--title] [--no-invite]
|
|
526
|
+
get <id> Wallet, tax form, contracts and work lines
|
|
527
|
+
link <id> New portal link (--email to send it)
|
|
528
|
+
contract <id> Draft: --title --kind fixed|hourly --rate 2000 [--frequency monthly]
|
|
529
|
+
send <contract-id> Sign as the business and send: --signature "Your Name"
|
|
530
|
+
end <contract-id> [--reason] [--end YYYY-MM-DD]
|
|
531
|
+
pending Work waiting for approval
|
|
532
|
+
approve <id>... Approve work lines; reject <id> --note "why"
|
|
533
|
+
bonus | expense --contract <id> --amount 250 --description "..."
|
|
534
|
+
1099 Totals and who needs a 1099-NEC (--year 2026)
|
|
535
|
+
offboard <id> End contracts and revoke the portal link
|
|
536
|
+
|
|
537
|
+
${colors.bright}payrun${colors.reset}
|
|
538
|
+
list Pay runs
|
|
539
|
+
create Draft from fixed fees due + approved work [--date YYYY-MM-DD]
|
|
540
|
+
get <id> Per-contractor amounts, lines and warnings
|
|
541
|
+
pay <id> --yes Send from the business payout wallet (owner login only)
|
|
542
|
+
record <id> You paid yourself: --item <item-id> --tx <hash>
|
|
543
|
+
delete <id> Discard a draft
|
|
544
|
+
|
|
545
|
+
${colors.bright}project${colors.reset}
|
|
546
|
+
list Projects (--business-id, --archived)
|
|
547
|
+
create --name, [--client-id] [--rate 120] [--budget 40]
|
|
548
|
+
get <id> Hours, unbilled, billed and paid
|
|
549
|
+
invoice <id> Bill unbilled time as a draft invoice
|
|
550
|
+
|
|
551
|
+
${colors.bright}time${colors.reset}
|
|
552
|
+
log --project <id> --minutes 90 | --hours 1.5 [--description] [--non-billable]
|
|
553
|
+
start --project <id> [--description] start a timer
|
|
554
|
+
stop stop your running timer
|
|
555
|
+
list --project <id> [--unbilled]
|
|
556
|
+
|
|
474
557
|
${colors.bright}item${colors.reset}
|
|
475
558
|
list Saved products/services (--business-id)
|
|
476
559
|
create Save one: --name, --price [--unit hour] [--description]
|
|
@@ -1164,6 +1247,335 @@ function printInvoiceDetails(invoice, { shareUrl } = {}) {
|
|
|
1164
1247
|
/**
|
|
1165
1248
|
* Invoice commands
|
|
1166
1249
|
*/
|
|
1250
|
+
async function handleClientCommand(subcommand, args, flags) {
|
|
1251
|
+
const client = createClient();
|
|
1252
|
+
if (subcommand !== 'portal-link' || args.length !== 1) {
|
|
1253
|
+
print.error('Usage: coinpay client portal-link <client-id> [--revoke]');
|
|
1254
|
+
process.exit(1);
|
|
1255
|
+
}
|
|
1256
|
+
if (flags.revoke) {
|
|
1257
|
+
await revokeClientPortalLink(client, args[0]);
|
|
1258
|
+
print.success('Portal link revoked');
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
const url = await createClientPortalLink(client, args[0]);
|
|
1262
|
+
if (flags.json) print.json({ url });
|
|
1263
|
+
else {
|
|
1264
|
+
print.success('Portal link (any earlier link no longer works):');
|
|
1265
|
+
console.log(url);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function hoursOf(minutes) {
|
|
1270
|
+
return minutes == null ? 'running' : Math.floor(minutes / 60) + 'h' + String(minutes % 60).padStart(2, '0') + 'm';
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const usdText = (n) => '$' + Number(n).toFixed(2);
|
|
1274
|
+
|
|
1275
|
+
function usageExit(usage) {
|
|
1276
|
+
print.error('Usage: ' + usage);
|
|
1277
|
+
process.exit(1);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
async function handleContractor(subcommand, args, flags) {
|
|
1281
|
+
const client = createClient();
|
|
1282
|
+
requireFlagValues(flags, ['business-id', 'name', 'email', 'country', 'title', 'kind', 'rate', 'frequency', 'start', 'end', 'note', 'signature', 'reason', 'amount', 'contract', 'description', 'status', 'year']);
|
|
1283
|
+
switch (subcommand) {
|
|
1284
|
+
case 'list': {
|
|
1285
|
+
const rows = await listContractors(client, { businessId: flags['business-id'], includeOffboarded: !!flags.all });
|
|
1286
|
+
if (flags.json) return print.json(rows);
|
|
1287
|
+
if (!rows.length) return print.info('No contractors');
|
|
1288
|
+
for (const c of rows) {
|
|
1289
|
+
const bits = [c.status, c.payout_currency || 'no wallet', c.tax_form ? c.tax_form + ' ' + c.tax_status : 'no tax form'];
|
|
1290
|
+
if (c.pending_approvals) bits.push(c.pending_approvals + ' to approve');
|
|
1291
|
+
print.info(c.id + ' ' + c.name + ' <' + c.email + '> ' + bits.join(' · '));
|
|
1292
|
+
}
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
case 'add': {
|
|
1296
|
+
if (!flags.name || !flags.email) usageExit('coinpay contractor add --name "Ada" --email ada@example.com [--country US] [--no-invite]');
|
|
1297
|
+
const result = await addContractor(client, {
|
|
1298
|
+
businessId: flags['business-id'],
|
|
1299
|
+
name: flags.name,
|
|
1300
|
+
email: flags.email,
|
|
1301
|
+
country: flags.country?.toUpperCase(),
|
|
1302
|
+
jobTitle: flags.title,
|
|
1303
|
+
sendInvite: !flags['no-invite'],
|
|
1304
|
+
});
|
|
1305
|
+
if (flags.json) return print.json(result);
|
|
1306
|
+
print.success('Contractor added: ' + result.contractor.id + (flags['no-invite'] ? '' : ' (invite emailed)'));
|
|
1307
|
+
return print.info('Portal link: ' + result.portal_url);
|
|
1308
|
+
}
|
|
1309
|
+
case 'get': {
|
|
1310
|
+
if (args.length !== 1) usageExit('coinpay contractor get <id>');
|
|
1311
|
+
const data = await getContractor(client, args[0]);
|
|
1312
|
+
if (flags.json) return print.json(data);
|
|
1313
|
+
const c = data.contractor;
|
|
1314
|
+
print.info(c.name + ' <' + c.email + '> ' + c.status);
|
|
1315
|
+
print.info('Wallet: ' + (c.payout_wallet ? c.payout_currency + ' ' + c.payout_wallet : 'not set'));
|
|
1316
|
+
print.info('Tax form: ' + (c.tax_form ? c.tax_form + ' ' + c.tax_status + (c.tax_id_last4 ? ' •••' + c.tax_id_last4 : '') : 'missing'));
|
|
1317
|
+
for (const k of data.contracts) {
|
|
1318
|
+
const terms = k.kind === 'fixed' ? usdText(k.rate) + ' ' + k.frequency : k.kind === 'hourly' ? usdText(k.rate) + '/h' : 'milestones';
|
|
1319
|
+
print.info(' contract ' + k.id + ' ' + k.title + ' ' + terms + ' ' + k.status);
|
|
1320
|
+
}
|
|
1321
|
+
for (const w of data.work_items.slice(0, 20)) print.info(' ' + w.work_date + ' ' + w.kind + ' ' + usdText(w.amount) + ' ' + w.status + ' ' + w.id);
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
case 'link': {
|
|
1325
|
+
if (args.length !== 1) usageExit('coinpay contractor link <id> [--email]');
|
|
1326
|
+
const url = await createContractorPortalLink(client, args[0], { sendEmail: !!flags.email });
|
|
1327
|
+
return flags.json ? print.json({ url }) : print.info(url);
|
|
1328
|
+
}
|
|
1329
|
+
case 'offboard': {
|
|
1330
|
+
if (args.length !== 1) usageExit('coinpay contractor offboard <id>');
|
|
1331
|
+
await offboardContractor(client, args[0]);
|
|
1332
|
+
return print.success('Offboarded');
|
|
1333
|
+
}
|
|
1334
|
+
case 'contract': {
|
|
1335
|
+
if (args.length !== 1 || !flags.title || !flags.kind) {
|
|
1336
|
+
usageExit('coinpay contractor contract <id> --title "Retainer" --kind fixed|hourly --rate 2000 [--frequency monthly] [--start YYYY-MM-DD]');
|
|
1337
|
+
}
|
|
1338
|
+
if (flags.kind === 'milestone') usageExit('milestone contracts are created in the dashboard (they need a milestone list)');
|
|
1339
|
+
const rate = parseNonNegativeAmount(String(flags.rate ?? ''));
|
|
1340
|
+
if (!rate) usageExit('--rate must be an amount with at most two decimals');
|
|
1341
|
+
const contract = await createContract(client, args[0], {
|
|
1342
|
+
title: flags.title,
|
|
1343
|
+
kind: flags.kind,
|
|
1344
|
+
rate,
|
|
1345
|
+
frequency: flags.frequency,
|
|
1346
|
+
startDate: flags.start,
|
|
1347
|
+
endDate: flags.end,
|
|
1348
|
+
});
|
|
1349
|
+
if (flags.json) return print.json(contract);
|
|
1350
|
+
print.success('Draft contract: ' + contract.id);
|
|
1351
|
+
return print.info('Send it with: coinpay contractor send ' + contract.id + ' --signature "Your Name"');
|
|
1352
|
+
}
|
|
1353
|
+
case 'send': {
|
|
1354
|
+
if (args.length !== 1 || !flags.signature) usageExit('coinpay contractor send <contract-id> --signature "Your Name"');
|
|
1355
|
+
const contract = await sendContract(client, args[0], { signature: flags.signature });
|
|
1356
|
+
return flags.json ? print.json(contract) : print.success('Signed and sent: ' + contract.title);
|
|
1357
|
+
}
|
|
1358
|
+
case 'end': {
|
|
1359
|
+
if (args.length !== 1) usageExit('coinpay contractor end <contract-id> [--reason "..."] [--end YYYY-MM-DD]');
|
|
1360
|
+
const contract = await endContract(client, args[0], { reason: flags.reason, endDate: flags.end });
|
|
1361
|
+
return flags.json ? print.json(contract) : print.success('Contract ended ' + contract.end_date);
|
|
1362
|
+
}
|
|
1363
|
+
case 'pending': {
|
|
1364
|
+
const rows = await listWorkItems(client, { businessId: flags['business-id'], status: flags.status || 'submitted' });
|
|
1365
|
+
if (flags.json) return print.json(rows);
|
|
1366
|
+
if (!rows.length) return print.info('Nothing waiting');
|
|
1367
|
+
for (const w of rows) {
|
|
1368
|
+
print.info(w.id + ' ' + (w.contractors?.name || '') + ' ' + w.kind + (w.minutes ? ' ' + (w.minutes / 60).toFixed(2) + 'h' : '') + ' ' + usdText(w.amount) + ' ' + w.description);
|
|
1369
|
+
}
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
case 'approve':
|
|
1373
|
+
case 'reject': {
|
|
1374
|
+
if (!args.length) usageExit('coinpay contractor ' + subcommand + ' <work-item-id>...' + (subcommand === 'reject' ? ' --note "why"' : ''));
|
|
1375
|
+
for (const workId of args) {
|
|
1376
|
+
const w = await reviewWorkItem(client, workId, { action: subcommand, note: flags.note });
|
|
1377
|
+
print.success((subcommand === 'approve' ? 'Approved ' : 'Rejected ') + w.id + ' ' + usdText(w.amount));
|
|
1378
|
+
}
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
case 'bonus':
|
|
1382
|
+
case 'expense': {
|
|
1383
|
+
if (!flags.contract || !flags.amount || !flags.description) {
|
|
1384
|
+
usageExit('coinpay contractor ' + subcommand + ' --contract <id> --amount 250 --description "..."');
|
|
1385
|
+
}
|
|
1386
|
+
const amount = parseNonNegativeAmount(String(flags.amount));
|
|
1387
|
+
if (!amount) usageExit('--amount must be an amount with at most two decimals');
|
|
1388
|
+
const w = await addContractorPayment(client, { businessId: flags['business-id'], contractId: flags.contract, kind: subcommand, amount, description: flags.description });
|
|
1389
|
+
return flags.json ? print.json(w) : print.success(subcommand + ' added: ' + usdText(w.amount) + ' (goes into the next pay run)');
|
|
1390
|
+
}
|
|
1391
|
+
case '1099': {
|
|
1392
|
+
const year = flags.year ? Number(flags.year) : new Date().getUTCFullYear() - 1;
|
|
1393
|
+
const data = await get1099Summary(client, { businessId: flags['business-id'], year });
|
|
1394
|
+
if (flags.json) return print.json(data);
|
|
1395
|
+
if (!data.contractors.length) return print.info('No contractor payments in ' + year);
|
|
1396
|
+
for (const r of data.contractors) {
|
|
1397
|
+
print.info(String(r.name).padEnd(30) + usdText(r.total_paid).padStart(14) + ' ' + (r.needs_1099_nec ? '1099-NEC' : r.missing_w9 ? 'MISSING W-9' : '-'));
|
|
1398
|
+
}
|
|
1399
|
+
return print.info('Full CSV with tax ids: the Contractors page, Tax forms & 1099 tab (owner only).');
|
|
1400
|
+
}
|
|
1401
|
+
default:
|
|
1402
|
+
usageExit('coinpay contractor <list|add|get|link|offboard|contract|send|end|pending|approve|reject|bonus|expense|1099>');
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
async function handlePayRun(subcommand, args, flags) {
|
|
1407
|
+
const client = createClient();
|
|
1408
|
+
requireFlagValues(flags, ['business-id', 'date', 'item', 'tx']);
|
|
1409
|
+
switch (subcommand) {
|
|
1410
|
+
case 'list': {
|
|
1411
|
+
const rows = await listPayRuns(client, { businessId: flags['business-id'] });
|
|
1412
|
+
if (flags.json) return print.json(rows);
|
|
1413
|
+
if (!rows.length) return print.info('No pay runs');
|
|
1414
|
+
for (const r of rows) print.info(r.id + ' #' + r.number + ' ' + r.pay_date + ' ' + usdText(r.total) + ' ' + r.status + ' ' + r.paid + '/' + r.contractors + ' paid');
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
case 'create': {
|
|
1418
|
+
const run = await createPayRun(client, { businessId: flags['business-id'], payDate: flags.date });
|
|
1419
|
+
if (flags.json) return print.json(run);
|
|
1420
|
+
print.success('Drafted pay run #' + run.number + ' for ' + usdText(run.total) + ': ' + run.id);
|
|
1421
|
+
return print.info('Review it with: coinpay payrun get ' + run.id);
|
|
1422
|
+
}
|
|
1423
|
+
case 'get': {
|
|
1424
|
+
if (args.length !== 1) usageExit('coinpay payrun get <id>');
|
|
1425
|
+
const data = await getPayRun(client, args[0]);
|
|
1426
|
+
if (flags.json) return print.json(data);
|
|
1427
|
+
const r = data.pay_run;
|
|
1428
|
+
print.info('Pay run #' + r.number + ' ' + r.pay_date + ' ' + usdText(r.total) + ' ' + r.status);
|
|
1429
|
+
for (const i of data.items) {
|
|
1430
|
+
print.info(' ' + i.id + ' ' + String(i.contractors?.name || '').padEnd(24) + usdText(i.amount).padStart(12) + ' ' + i.status + (i.tx_hash ? ' ' + i.tx_hash : '') + (i.can_send_from_wallet ? '' : ' (pay manually)'));
|
|
1431
|
+
for (const w of i.warnings || []) print.info(' ! ' + w);
|
|
1432
|
+
}
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
case 'pay': {
|
|
1436
|
+
if (args.length !== 1) usageExit('coinpay payrun pay <id> --yes (sends from the business payout wallet; needs `coinpay login` as the owner)');
|
|
1437
|
+
if (!flags.yes) {
|
|
1438
|
+
print.error('This sends crypto and cannot be undone. Review with `coinpay payrun get ' + args[0] + '`, then add --yes.');
|
|
1439
|
+
process.exit(1);
|
|
1440
|
+
}
|
|
1441
|
+
const result = await payPayRun(client, args[0]);
|
|
1442
|
+
if (flags.json) return print.json(result);
|
|
1443
|
+
for (const o of result.outcomes) {
|
|
1444
|
+
const line = o.contractor + ': ' + o.status + (o.tx_hash ? ' ' + o.tx_hash : '') + (o.error ? ' · ' + o.error : '');
|
|
1445
|
+
if (o.error) print.error(line);
|
|
1446
|
+
else print.success(line);
|
|
1447
|
+
}
|
|
1448
|
+
return print.info('Pay run is ' + result.pay_run.status);
|
|
1449
|
+
}
|
|
1450
|
+
case 'record': {
|
|
1451
|
+
if (args.length !== 1 || !flags.item || !flags.tx) usageExit('coinpay payrun record <id> --item <item-id> --tx <hash> (you paid from your own wallet)');
|
|
1452
|
+
const result = await recordPayRunPayments(client, args[0], [{ itemId: flags.item, txHash: flags.tx }]);
|
|
1453
|
+
if (flags.json) return print.json(result);
|
|
1454
|
+
const o = result.outcomes[0];
|
|
1455
|
+
return o.error ? print.error(o.error) : print.success('Recorded. Pay run is ' + result.pay_run.status);
|
|
1456
|
+
}
|
|
1457
|
+
case 'delete': {
|
|
1458
|
+
if (args.length !== 1) usageExit('coinpay payrun delete <id>');
|
|
1459
|
+
await deletePayRun(client, args[0]);
|
|
1460
|
+
return print.success('Draft deleted; its lines go back to the approved pool');
|
|
1461
|
+
}
|
|
1462
|
+
default:
|
|
1463
|
+
usageExit('coinpay payrun <list|create|get|pay|record|delete>');
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
async function handleProject(subcommand, args, flags) {
|
|
1468
|
+
const client = createClient();
|
|
1469
|
+
requireFlagValues(flags, ['business-id', 'name', 'client-id', 'rate', 'budget', 'currency']);
|
|
1470
|
+
switch (subcommand) {
|
|
1471
|
+
case 'list': {
|
|
1472
|
+
const rows = await listProjects(client, { businessId: flags['business-id'], includeArchived: !!flags.archived });
|
|
1473
|
+
if (flags.json) return print.json(rows);
|
|
1474
|
+
if (!rows.length) return print.info('No projects');
|
|
1475
|
+
for (const p of rows) {
|
|
1476
|
+
print.info(p.id + ' ' + p.name + (p.hourly_rate != null ? ' ' + p.hourly_rate + '/hr' : '') + (p.status === 'archived' ? ' (archived)' : ''));
|
|
1477
|
+
}
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
case 'create': {
|
|
1481
|
+
if (!flags.name) {
|
|
1482
|
+
print.error('--name is required');
|
|
1483
|
+
process.exit(1);
|
|
1484
|
+
}
|
|
1485
|
+
const rate = flags.rate === undefined ? undefined : parseNonNegativeAmount(String(flags.rate));
|
|
1486
|
+
if (rate === null) {
|
|
1487
|
+
print.error('--rate must be an amount with at most two decimals');
|
|
1488
|
+
process.exit(1);
|
|
1489
|
+
}
|
|
1490
|
+
const project = await createProject(client, {
|
|
1491
|
+
businessId: flags['business-id'],
|
|
1492
|
+
name: flags.name,
|
|
1493
|
+
clientId: flags['client-id'],
|
|
1494
|
+
hourlyRate: rate,
|
|
1495
|
+
currency: flags.currency?.toUpperCase(),
|
|
1496
|
+
budgetHours: flags.budget === undefined ? undefined : Number(flags.budget),
|
|
1497
|
+
});
|
|
1498
|
+
if (flags.json) return print.json(project);
|
|
1499
|
+
return print.success('Project created: ' + project.id);
|
|
1500
|
+
}
|
|
1501
|
+
case 'get': {
|
|
1502
|
+
if (args.length !== 1) {
|
|
1503
|
+
print.error('Usage: coinpay project get <id>');
|
|
1504
|
+
process.exit(1);
|
|
1505
|
+
}
|
|
1506
|
+
const { project, stats } = await getProject(client, args[0]);
|
|
1507
|
+
if (flags.json) return print.json({ project, stats });
|
|
1508
|
+
print.info(project.name + (project.hourly_rate != null ? ' ' + project.hourly_rate + '/hr' : ''));
|
|
1509
|
+
print.info('Hours: ' + stats.hours + (project.budget_hours ? ' of ' + project.budget_hours : '') + ' (' + stats.billable_hours + ' billable)');
|
|
1510
|
+
print.info('Unbilled: ' + stats.unbilled_amount + ' Billed: ' + stats.billed_amount + ' Paid: ' + stats.paid_amount);
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
case 'invoice': {
|
|
1514
|
+
if (args.length !== 1) {
|
|
1515
|
+
print.error('Usage: coinpay project invoice <id>');
|
|
1516
|
+
process.exit(1);
|
|
1517
|
+
}
|
|
1518
|
+
const result = await invoiceProjectTime(client, args[0]);
|
|
1519
|
+
if (flags.json) return print.json(result);
|
|
1520
|
+
print.success('Draft ' + result.invoice.invoice_number + ' for ' + result.invoice.amount + ' from ' + result.entries_billed + ' entries');
|
|
1521
|
+
return print.info('Review and send: coinpay invoice send ' + result.invoice.id);
|
|
1522
|
+
}
|
|
1523
|
+
default:
|
|
1524
|
+
print.error('Usage: coinpay project <list|create|get|invoice>');
|
|
1525
|
+
process.exit(1);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
async function handleTime(subcommand, args, flags) {
|
|
1530
|
+
const client = createClient();
|
|
1531
|
+
requireFlagValues(flags, ['project', 'minutes', 'hours', 'description', 'business-id']);
|
|
1532
|
+
switch (subcommand) {
|
|
1533
|
+
case 'log': {
|
|
1534
|
+
const minutes = flags.minutes !== undefined ? Number(flags.minutes) : Math.round(Number(flags.hours) * 60);
|
|
1535
|
+
if (!flags.project || !Number.isInteger(minutes) || minutes < 1) {
|
|
1536
|
+
print.error('Usage: coinpay time log --project <id> --minutes 90 (or --hours 1.5) [--description "..."]');
|
|
1537
|
+
process.exit(1);
|
|
1538
|
+
}
|
|
1539
|
+
const entry = await logTime(client, {
|
|
1540
|
+
projectId: flags.project,
|
|
1541
|
+
minutes,
|
|
1542
|
+
description: flags.description,
|
|
1543
|
+
billable: flags['non-billable'] ? false : undefined,
|
|
1544
|
+
});
|
|
1545
|
+
if (flags.json) return print.json(entry);
|
|
1546
|
+
return print.success('Logged ' + hoursOf(entry.minutes));
|
|
1547
|
+
}
|
|
1548
|
+
case 'start': {
|
|
1549
|
+
if (!flags.project) {
|
|
1550
|
+
print.error('Usage: coinpay time start --project <id> [--description "..."]');
|
|
1551
|
+
process.exit(1);
|
|
1552
|
+
}
|
|
1553
|
+
const entry = await startTimer(client, { projectId: flags.project, description: flags.description });
|
|
1554
|
+
if (flags.json) return print.json(entry);
|
|
1555
|
+
return print.success('Timer started (' + entry.id + ')');
|
|
1556
|
+
}
|
|
1557
|
+
case 'stop': {
|
|
1558
|
+
const entry = await stopTimer(client, { entryId: args[0], businessId: flags['business-id'] });
|
|
1559
|
+
if (flags.json) return print.json(entry);
|
|
1560
|
+
return print.success('Timer stopped: ' + hoursOf(entry.minutes));
|
|
1561
|
+
}
|
|
1562
|
+
case 'list': {
|
|
1563
|
+
const rows = await listTimeEntries(client, { projectId: flags.project, businessId: flags['business-id'], unbilled: !!flags.unbilled });
|
|
1564
|
+
if (flags.json) return print.json(rows);
|
|
1565
|
+
if (!rows.length) return print.info('No time logged');
|
|
1566
|
+
for (const e of rows) {
|
|
1567
|
+
print.info(
|
|
1568
|
+
e.started_at.slice(0, 10) + ' ' + hoursOf(e.minutes).padStart(8) + ' ' + (e.invoice_id ? 'billed ' : e.billable ? 'unbilled' : 'internal') + ' ' + (e.description || '')
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
default:
|
|
1574
|
+
print.error('Usage: coinpay time <log|start|stop|list>');
|
|
1575
|
+
process.exit(1);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1167
1579
|
async function handleCatalog(kind, subcommand, args, flags) {
|
|
1168
1580
|
const client = createClient();
|
|
1169
1581
|
const isItem = kind === 'item';
|
|
@@ -1686,9 +2098,82 @@ async function handleInvoice(subcommand, args, flags) {
|
|
|
1686
2098
|
break;
|
|
1687
2099
|
}
|
|
1688
2100
|
|
|
2101
|
+
case 'aging': {
|
|
2102
|
+
requireFlagValues(flags, ['business-id']);
|
|
2103
|
+
const report = await getArAging(client, { businessId: flags['business-id'] });
|
|
2104
|
+
if (flags.json) {
|
|
2105
|
+
print.json(report);
|
|
2106
|
+
break;
|
|
2107
|
+
}
|
|
2108
|
+
if (!report.totals?.length) {
|
|
2109
|
+
print.info('Nothing outstanding');
|
|
2110
|
+
break;
|
|
2111
|
+
}
|
|
2112
|
+
const cols = ['current', '1_30', '31_60', '61_90', '90_plus', 'total'];
|
|
2113
|
+
print.info(' current 1-30 31-60 61-90 90+ total');
|
|
2114
|
+
for (const row of [...report.totals.map((t) => ({ ...t, name: 'TOTAL ' + t.currency })), ...report.clients]) {
|
|
2115
|
+
print.info(
|
|
2116
|
+
String(row.name).slice(0, 24).padEnd(24) +
|
|
2117
|
+
cols.map((c) => String(row[c] ? row[c].toFixed(2) : '-').padStart(9)).join('')
|
|
2118
|
+
);
|
|
2119
|
+
}
|
|
2120
|
+
break;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
case 'settings': {
|
|
2124
|
+
requireFlagValues(flags, ['business-id', 'reminders', 'late-fee', 'late-fee-after', 'brand-color', 'logo-url', 'footer']);
|
|
2125
|
+
const changes = {};
|
|
2126
|
+
if (flags.reminders !== undefined) {
|
|
2127
|
+
if (flags.reminders === 'off') changes.reminders_enabled = false;
|
|
2128
|
+
else {
|
|
2129
|
+
const days = String(flags.reminders).split(',').map((d) => Number(d.trim()));
|
|
2130
|
+
if (days.some((d) => !Number.isInteger(d))) {
|
|
2131
|
+
print.error('--reminders must be days relative to the due date, like "-3,0,7", or off');
|
|
2132
|
+
process.exit(1);
|
|
2133
|
+
}
|
|
2134
|
+
changes.reminders_enabled = true;
|
|
2135
|
+
changes.reminder_days = days;
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
if (flags['late-fee'] !== undefined) {
|
|
2139
|
+
const fee = String(flags['late-fee']).trim();
|
|
2140
|
+
const m = /^(\d+(?:\.\d{1,2})?)(%?)$/.exec(fee);
|
|
2141
|
+
if (fee === 'none') changes.late_fee_type = 'none';
|
|
2142
|
+
else if (m) Object.assign(changes, { late_fee_type: m[2] ? 'percent' : 'fixed', late_fee_value: Number(m[1]) });
|
|
2143
|
+
else {
|
|
2144
|
+
print.error('--late-fee must be a percent like 1.5%, an amount like 25, or none');
|
|
2145
|
+
process.exit(1);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
if (flags['late-fee-after'] !== undefined) changes.late_fee_after_days = Number(flags['late-fee-after']);
|
|
2149
|
+
if (flags['brand-color'] !== undefined) changes.brand_color = flags['brand-color'] === 'none' ? null : flags['brand-color'];
|
|
2150
|
+
if (flags['logo-url'] !== undefined) changes.logo_url = flags['logo-url'] === 'none' ? null : flags['logo-url'];
|
|
2151
|
+
if (flags.footer !== undefined) changes.invoice_footer = flags.footer === 'none' ? null : flags.footer;
|
|
2152
|
+
|
|
2153
|
+
const settings = Object.keys(changes).length
|
|
2154
|
+
? await updateInvoiceSettings(client, { businessId: flags['business-id'], ...changes })
|
|
2155
|
+
: await getInvoiceSettings(client, { businessId: flags['business-id'] });
|
|
2156
|
+
if (flags.json) {
|
|
2157
|
+
print.json(settings);
|
|
2158
|
+
break;
|
|
2159
|
+
}
|
|
2160
|
+
print.info('Reminders: ' + (settings.reminders_enabled ? settings.reminder_days.join(', ') + ' days from due' : 'off'));
|
|
2161
|
+
print.info(
|
|
2162
|
+
'Late fee: ' +
|
|
2163
|
+
(settings.late_fee_type === 'none'
|
|
2164
|
+
? 'none'
|
|
2165
|
+
: (settings.late_fee_type === 'percent' ? settings.late_fee_value + '%' : settings.late_fee_value) +
|
|
2166
|
+
', ' + settings.late_fee_after_days + ' days after due')
|
|
2167
|
+
);
|
|
2168
|
+
print.info('Brand colour: ' + (settings.brand_color || 'default'));
|
|
2169
|
+
print.info('Logo: ' + (settings.logo_url || 'none'));
|
|
2170
|
+
if (settings.invoice_footer) print.info('Footer: ' + settings.invoice_footer);
|
|
2171
|
+
break;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
1689
2174
|
default:
|
|
1690
2175
|
print.error('Unknown invoice command: ' + subcommand);
|
|
1691
|
-
print.info('Available: create, list, get, update, publish, send, delete');
|
|
2176
|
+
print.info('Available: create, list, get, update, publish, send, delete, aging, settings');
|
|
1692
2177
|
process.exit(1);
|
|
1693
2178
|
}
|
|
1694
2179
|
}
|
|
@@ -4764,6 +5249,28 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
4764
5249
|
await handleInvoice(subcommand, args, flags);
|
|
4765
5250
|
break;
|
|
4766
5251
|
|
|
5252
|
+
case 'client':
|
|
5253
|
+
await handleClientCommand(subcommand, args, flags);
|
|
5254
|
+
break;
|
|
5255
|
+
|
|
5256
|
+
case 'project':
|
|
5257
|
+
await handleProject(subcommand, args, flags);
|
|
5258
|
+
break;
|
|
5259
|
+
|
|
5260
|
+
case 'contractor':
|
|
5261
|
+
case 'contractors':
|
|
5262
|
+
await handleContractor(subcommand, args, flags);
|
|
5263
|
+
break;
|
|
5264
|
+
|
|
5265
|
+
case 'payrun':
|
|
5266
|
+
case 'payruns':
|
|
5267
|
+
await handlePayRun(subcommand, args, flags);
|
|
5268
|
+
break;
|
|
5269
|
+
|
|
5270
|
+
case 'time':
|
|
5271
|
+
await handleTime(subcommand, args, flags);
|
|
5272
|
+
break;
|
|
5273
|
+
|
|
4767
5274
|
case 'item':
|
|
4768
5275
|
case 'tax-rate':
|
|
4769
5276
|
await handleCatalog(command, subcommand, args, flags);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/coinpay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|