@profullstack/coinpay 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ 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
+
8
23
  ## [0.18.0] - 2026-10-17
9
24
 
10
25
  ### Added
package/bin/coinpay.js CHANGED
@@ -46,6 +46,26 @@ import {
46
46
  listTimeEntries,
47
47
  invoiceProjectTime,
48
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';
49
69
  import { readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from 'fs';
50
70
  import { execSync, spawn } from 'child_process';
51
71
  import { createInterface } from 'readline';
@@ -500,6 +520,28 @@ ${colors.cyan}Commands:${colors.reset}
500
520
  portal-link <client-id> Create a portal link listing all their invoices
501
521
  (replaces the old link; --revoke to turn it off)
502
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
+
503
545
  ${colors.bright}project${colors.reset}
504
546
  list Projects (--business-id, --archived)
505
547
  create --name, [--client-id] [--rate 120] [--budget 40]
@@ -640,7 +682,7 @@ ${colors.cyan}Commands:${colors.reset}
640
682
  due [--days 7] What is due and overdue across every account (--days up to 60)
641
683
  recurring Every recurring charge detected, with next date and monthly cost
642
684
  due-day <id> <1-31|clear> A card/loan's statement due day (--amount 810.46, or balance)
643
- daily [show|on|off|send-now] Daily summary email: due soon, new activity, position (--hour 7 --to …)
685
+ daily [show|on|off|send-now] Daily summary email: spend vs target, top cuts, due soon, position (--hour 7 --to …, --target 100)
644
686
 
645
687
  ${colors.bright}escrow${colors.reset}
646
688
  create Create a new escrow
@@ -1228,6 +1270,200 @@ function hoursOf(minutes) {
1228
1270
  return minutes == null ? 'running' : Math.floor(minutes / 60) + 'h' + String(minutes % 60).padStart(2, '0') + 'm';
1229
1271
  }
1230
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
+
1231
1467
  async function handleProject(subcommand, args, flags) {
1232
1468
  const client = createClient();
1233
1469
  requireFlagValues(flags, ['business-id', 'name', 'client-id', 'rate', 'budget', 'currency']);
@@ -5021,6 +5257,16 @@ async function handleLightning(subcommand, args, flags) {
5021
5257
  await handleProject(subcommand, args, flags);
5022
5258
  break;
5023
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
+
5024
5270
  case 'time':
5025
5271
  await handleTime(subcommand, args, flags);
5026
5272
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/coinpay",
3
- "version": "0.18.0",
3
+ "version": "0.20.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",
@@ -0,0 +1,124 @@
1
+ import type { CoinPayClient } from './client.js';
2
+
3
+ export interface Contractor {
4
+ id: string;
5
+ business_id: string;
6
+ name: string;
7
+ email: string;
8
+ country: string | null;
9
+ job_title: string | null;
10
+ status: 'invited' | 'active' | 'offboarded';
11
+ payout_currency: string | null;
12
+ payout_wallet: string | null;
13
+ payout_wallet_changed_at: string | null;
14
+ tax_form: 'W9' | 'W8BEN' | 'W8BENE' | null;
15
+ tax_status: 'missing' | 'submitted' | 'verified';
16
+ tax_legal_name: string | null;
17
+ tax_id_last4: string | null;
18
+ pending_approvals?: number;
19
+ }
20
+
21
+ export interface ContractorMilestone {
22
+ id: string;
23
+ contract_id: string;
24
+ title: string;
25
+ amount: string;
26
+ due_date: string | null;
27
+ position: number;
28
+ status: 'pending' | 'submitted' | 'approved' | 'paid';
29
+ }
30
+
31
+ export interface ContractorContract {
32
+ id: string;
33
+ contractor_id: string;
34
+ title: string;
35
+ scope: string | null;
36
+ kind: 'fixed' | 'hourly' | 'milestone';
37
+ rate: string | null;
38
+ frequency: 'weekly' | 'biweekly' | 'semimonthly' | 'monthly' | null;
39
+ start_date: string;
40
+ end_date: string | null;
41
+ next_pay_date: string | null;
42
+ status: 'draft' | 'sent' | 'active' | 'ended';
43
+ contractor_milestones?: ContractorMilestone[];
44
+ }
45
+
46
+ export interface ContractorWorkItem {
47
+ id: string;
48
+ contractor_id: string;
49
+ contract_id: string;
50
+ kind: 'hours' | 'milestone' | 'expense' | 'bonus' | 'fixed';
51
+ description: string;
52
+ work_date: string;
53
+ minutes: number | null;
54
+ amount: string;
55
+ status: 'submitted' | 'approved' | 'rejected' | 'paid';
56
+ review_note: string | null;
57
+ }
58
+
59
+ export interface PayRun {
60
+ id: string;
61
+ business_id: string;
62
+ number: number;
63
+ pay_date: string;
64
+ status: 'draft' | 'processing' | 'paid' | 'partially_paid';
65
+ total: string;
66
+ auto_drafted: boolean;
67
+ paid_at: string | null;
68
+ contractors?: number;
69
+ paid?: number;
70
+ }
71
+
72
+ export interface PayRunItem {
73
+ id: string;
74
+ contractor_id: string;
75
+ amount: string;
76
+ status: 'pending' | 'processing' | 'paid' | 'failed' | 'indeterminate';
77
+ method: 'wallet' | 'manual' | null;
78
+ tx_hash: string | null;
79
+ error: string | null;
80
+ invoice_number: string;
81
+ warnings?: string[];
82
+ can_send_from_wallet?: boolean;
83
+ }
84
+
85
+ export interface PayOutcome {
86
+ item_id: string;
87
+ contractor: string;
88
+ status: string;
89
+ tx_hash?: string | null;
90
+ error?: string;
91
+ }
92
+
93
+ export function listContractors(client: CoinPayClient, options?: { businessId?: string; includeOffboarded?: boolean }): Promise<Contractor[]>;
94
+ export function getContractor(client: CoinPayClient, contractorId: string): Promise<{ contractor: Contractor; contracts: ContractorContract[]; work_items: ContractorWorkItem[]; payments: PayRunItem[] }>;
95
+ export function addContractor(client: CoinPayClient, input: { businessId?: string; name: string; email: string; country?: string; jobTitle?: string; sendInvite?: boolean }): Promise<{ contractor: Contractor; portal_url: string }>;
96
+ export function createContractorPortalLink(client: CoinPayClient, contractorId: string, options?: { sendEmail?: boolean }): Promise<string>;
97
+ export function offboardContractor(client: CoinPayClient, contractorId: string): Promise<{ success: boolean }>;
98
+ export function createContract(
99
+ client: CoinPayClient,
100
+ contractorId: string,
101
+ input: {
102
+ title: string;
103
+ kind: 'fixed' | 'hourly' | 'milestone';
104
+ scope?: string;
105
+ rate?: number;
106
+ frequency?: 'weekly' | 'biweekly' | 'semimonthly' | 'monthly';
107
+ startDate?: string;
108
+ endDate?: string;
109
+ firstPayDate?: string;
110
+ milestones?: { title: string; amount: number; due_date?: string }[];
111
+ }
112
+ ): Promise<ContractorContract>;
113
+ export function sendContract(client: CoinPayClient, contractId: string, input: { signature: string }): Promise<ContractorContract>;
114
+ export function endContract(client: CoinPayClient, contractId: string, input?: { reason?: string; endDate?: string }): Promise<ContractorContract>;
115
+ export function listWorkItems(client: CoinPayClient, options?: { businessId?: string; status?: string; contractorId?: string }): Promise<ContractorWorkItem[]>;
116
+ export function reviewWorkItem(client: CoinPayClient, workItemId: string, input: { action: 'approve' | 'reject'; note?: string }): Promise<ContractorWorkItem>;
117
+ export function addContractorPayment(client: CoinPayClient, input: { businessId?: string; contractId: string; kind: 'bonus' | 'expense'; amount: number; description: string }): Promise<ContractorWorkItem>;
118
+ export function listPayRuns(client: CoinPayClient, options?: { businessId?: string }): Promise<PayRun[]>;
119
+ export function getPayRun(client: CoinPayClient, payRunId: string): Promise<{ pay_run: PayRun; items: PayRunItem[]; sendable_coins: string[] }>;
120
+ export function createPayRun(client: CoinPayClient, options?: { businessId?: string; payDate?: string }): Promise<PayRun>;
121
+ export function deletePayRun(client: CoinPayClient, payRunId: string): Promise<{ success: boolean }>;
122
+ export function payPayRun(client: CoinPayClient, payRunId: string, options?: { itemIds?: string[] }): Promise<{ success: boolean; pay_run: PayRun; outcomes: PayOutcome[] }>;
123
+ export function recordPayRunPayments(client: CoinPayClient, payRunId: string, payments: { itemId: string; txHash: string }[]): Promise<{ success: boolean; pay_run: PayRun; outcomes: PayOutcome[] }>;
124
+ export function get1099Summary(client: CoinPayClient, options?: { businessId?: string; year?: number }): Promise<{ year: number; threshold: number; contractors: { contractor_id: string; name: string; tax_form: string | null; total_paid: number; needs_1099_nec: boolean; missing_w9: boolean }[] }>;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Contractors and pay runs: invite contractors, contract with them, approve the
3
+ * work they submit and pay the whole team in crypto.
4
+ *
5
+ * An API key is locked to its own business, so `businessId` is optional when
6
+ * the client was created with one. Paying a run needs a signed-in owner
7
+ * (`coinpay login`), never an API key.
8
+ */
9
+
10
+ const q = (params) => {
11
+ const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '');
12
+ return entries.length ? '?' + new URLSearchParams(entries.map(([k, v]) => [k, String(v)])).toString() : '';
13
+ };
14
+ const json = (method, body) => ({ method, body: JSON.stringify(body) });
15
+ const id = (v) => encodeURIComponent(v);
16
+
17
+ export async function listContractors(client, { businessId, includeOffboarded } = {}) {
18
+ const data = await client.request('/contractors' + q({ business_id: businessId, include_offboarded: includeOffboarded ? 'true' : undefined }));
19
+ return data.contractors ?? [];
20
+ }
21
+
22
+ /** A contractor with contracts (and milestones), work lines and payments. */
23
+ export async function getContractor(client, contractorId) {
24
+ return client.request('/contractors/' + id(contractorId));
25
+ }
26
+
27
+ /** Invite a contractor. They are emailed a portal link unless sendInvite is false. */
28
+ export async function addContractor(client, { businessId, name, email, country, jobTitle, sendInvite }) {
29
+ const body = { name, email };
30
+ if (businessId) body.business_id = businessId;
31
+ if (country) body.country = country;
32
+ if (jobTitle) body.job_title = jobTitle;
33
+ if (sendInvite === false) body.send_invite = false;
34
+ return client.request('/contractors', json('POST', body));
35
+ }
36
+
37
+ /** Issue a new portal link (the old one stops working). Returns the URL. */
38
+ export async function createContractorPortalLink(client, contractorId, { sendEmail } = {}) {
39
+ const data = await client.request(`/contractors/${id(contractorId)}/portal-link`, json('POST', { send_email: !!sendEmail }));
40
+ return data.url;
41
+ }
42
+
43
+ /** Offboard: ends live contracts and revokes the portal link. Records are kept. */
44
+ export async function offboardContractor(client, contractorId) {
45
+ return client.request('/contractors/' + id(contractorId), { method: 'DELETE' });
46
+ }
47
+
48
+ /**
49
+ * Draft a contract. kind: 'fixed' (rate per frequency), 'hourly' (rate per
50
+ * hour) or 'milestone' (milestones: [{ title, amount, due_date? }]).
51
+ */
52
+ export async function createContract(client, contractorId, { title, scope, kind, rate, frequency, startDate, endDate, firstPayDate, milestones }) {
53
+ const body = { title, kind };
54
+ if (scope) body.scope = scope;
55
+ if (rate !== undefined) body.rate = rate;
56
+ if (frequency) body.frequency = frequency;
57
+ if (startDate) body.start_date = startDate;
58
+ if (endDate) body.end_date = endDate;
59
+ if (firstPayDate) body.first_pay_date = firstPayDate;
60
+ if (milestones) body.milestones = milestones;
61
+ const data = await client.request(`/contractors/${id(contractorId)}/contracts`, json('POST', body));
62
+ return data.contract;
63
+ }
64
+
65
+ /** Sign a draft contract as the business and send it to the contractor. */
66
+ export async function sendContract(client, contractId, { signature }) {
67
+ const data = await client.request('/contractor-contracts/' + id(contractId), json('PATCH', { action: 'send', signature }));
68
+ return data.contract;
69
+ }
70
+
71
+ export async function endContract(client, contractId, { reason, endDate } = {}) {
72
+ const data = await client.request('/contractor-contracts/' + id(contractId), json('PATCH', { action: 'end', reason, end_date: endDate }));
73
+ return data.contract;
74
+ }
75
+
76
+ /** Work lines: hours, milestones, expenses, bonuses and fixed fees. status: submitted|approved|rejected|paid */
77
+ export async function listWorkItems(client, { businessId, status, contractorId } = {}) {
78
+ const data = await client.request('/contractor-work-items' + q({ business_id: businessId, status, contractor_id: contractorId }));
79
+ return data.work_items ?? [];
80
+ }
81
+
82
+ /** Approve or reject a submission. A rejection needs a note. */
83
+ export async function reviewWorkItem(client, workItemId, { action, note }) {
84
+ const data = await client.request('/contractor-work-items/' + id(workItemId), json('PATCH', { action, note }));
85
+ return data.work_item;
86
+ }
87
+
88
+ /** Add a bonus or an expense reimbursement (approved immediately). */
89
+ export async function addContractorPayment(client, { businessId, contractId, kind, amount, description }) {
90
+ const body = { contract_id: contractId, kind, amount, description };
91
+ if (businessId) body.business_id = businessId;
92
+ const data = await client.request('/contractor-work-items', json('POST', body));
93
+ return data.work_item;
94
+ }
95
+
96
+ export async function listPayRuns(client, { businessId } = {}) {
97
+ const data = await client.request('/pay-runs' + q({ business_id: businessId }));
98
+ return data.pay_runs ?? [];
99
+ }
100
+
101
+ /** A run with one item per contractor, its lines, and any warnings. */
102
+ export async function getPayRun(client, payRunId) {
103
+ return client.request('/pay-runs/' + id(payRunId));
104
+ }
105
+
106
+ /** Draft a pay run (fixed fees due by payDate plus approved work). Moves no money. */
107
+ export async function createPayRun(client, { businessId, payDate } = {}) {
108
+ const body = {};
109
+ if (businessId) body.business_id = businessId;
110
+ if (payDate) body.pay_date = payDate;
111
+ const data = await client.request('/pay-runs', json('POST', body));
112
+ return data.pay_run;
113
+ }
114
+
115
+ export async function deletePayRun(client, payRunId) {
116
+ return client.request('/pay-runs/' + id(payRunId), { method: 'DELETE' });
117
+ }
118
+
119
+ /** Send a run (or some items) from the business payout wallet. Owner session only. */
120
+ export async function payPayRun(client, payRunId, { itemIds } = {}) {
121
+ return client.request(`/pay-runs/${id(payRunId)}/pay`, json('POST', itemIds ? { method: 'wallet', item_ids: itemIds } : { method: 'wallet' }));
122
+ }
123
+
124
+ /** Record payments made from your own wallet: [{ itemId, txHash }]. Owner session only. */
125
+ export async function recordPayRunPayments(client, payRunId, payments) {
126
+ return client.request(
127
+ `/pay-runs/${id(payRunId)}/pay`,
128
+ json('POST', { method: 'manual', payments: payments.map((p) => ({ item_id: p.itemId, tx_hash: p.txHash })) })
129
+ );
130
+ }
131
+
132
+ /** Per-contractor totals for a calendar year and who needs a 1099-NEC. */
133
+ export async function get1099Summary(client, { businessId, year } = {}) {
134
+ return client.request('/contractors/1099' + q({ business_id: businessId, year }));
135
+ }
@@ -891,10 +891,21 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
891
891
  const action = flags.off ? 'off' : args[0] || 'show';
892
892
  const describe = (s) => s
893
893
  ? `Daily summary ${s.active ? 'on' : 'off'} · ${String(s.hour).padStart(2, '0')}:00 ${s.timezone} · to ${s.recipients.join(', ')}${s.nextRunAt ? ` · next ${s.nextRunAt}` : ''}${s.lastSentAt ? ` · last ${s.lastSentAt}` : ''}`
894
- : 'No daily summary configured. Turn it on with: coinpay finances daily on --hour 7 --to you@example.com';
894
+ : 'No daily summary configured. Turn it on with: coinpay finances daily on --hour 7';
895
+ const targetText = (t) => (t === null || t === undefined ? 'no daily spend target' : `target $${t}/day`);
896
+ if (flags.target !== undefined) {
897
+ const raw = String(flags.target);
898
+ const target = ['off', 'clear', 'none'].includes(raw) ? null : Number(raw.replace(/^\$/, ''));
899
+ if (target !== null && (!Number.isFinite(target) || target < 0)) throw new CliExit(EXIT.INVALID, 'The target is a daily amount, e.g. --target 100, or --target clear');
900
+ const data = await api.setDailySpendTarget(client, target);
901
+ if (action === 'show') {
902
+ emit({ schedule: data.schedule, target: data.target }, `${describe(data.schedule)}\n${target === null ? 'Cleared the daily spend target.' : `Daily spend target set to $${data.target}/day.`}`);
903
+ return EXIT.OK;
904
+ }
905
+ }
895
906
  if (action === 'show') {
896
- const s = await api.getDailySummary(client);
897
- emit({ schedule: s }, describe(s));
907
+ const { schedule: s, target } = await api.getDailySummaryWithTarget(client);
908
+ emit({ schedule: s, target }, `${describe(s)} · ${targetText(target)}`);
898
909
  return EXIT.OK;
899
910
  }
900
911
  if (action === 'on' || action === 'set') {
@@ -917,7 +928,7 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
917
928
  emit(data, `Sent to ${data.sent.join(', ') || 'nobody'} (${data.due} due, ${data.overdue} overdue)${data.failed.length ? `; failed: ${data.failed.map((f) => `${f.to} (${f.error})`).join(', ')}` : ''}`);
918
929
  return data.sent.length > 0 ? EXIT.OK : EXIT.FAILURE;
919
930
  }
920
- throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances daily [show|on --hour 7 --to a@x|off|send-now]');
931
+ throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances daily [show|on --hour 7 --to a@x|off|send-now] [--target 100|--target clear]');
921
932
  }
922
933
 
923
934
  case 'payloads': {
@@ -461,6 +461,12 @@ export async function getDailySummary(client) {
461
461
  return data.schedule;
462
462
  }
463
463
 
464
+ /** The daily summary schedule plus the owner's $/day spend target (null when none). */
465
+ export async function getDailySummaryWithTarget(client) {
466
+ const data = await call(client, '/finances/email-schedules?kind=daily_summary');
467
+ return { schedule: data.schedule, target: data.dailySpendTarget ?? null };
468
+ }
469
+
464
470
  export async function setDailySummary(client, { weekdays, hour, timezone, recipients, active } = {}) {
465
471
  const data = await call(client, '/finances/email-schedules', {
466
472
  method: 'POST',
@@ -469,6 +475,15 @@ export async function setDailySummary(client, { weekdays, hour, timezone, recipi
469
475
  return data.schedule;
470
476
  }
471
477
 
478
+ /** Set the daily summary's $/day spend target, or clear it with null. Does not turn the summary on. */
479
+ export async function setDailySpendTarget(client, target) {
480
+ const data = await call(client, '/finances/email-schedules', {
481
+ method: 'POST',
482
+ body: JSON.stringify({ kind: 'daily_summary', target }),
483
+ });
484
+ return { schedule: data.schedule, target: data.dailySpendTarget ?? null };
485
+ }
486
+
472
487
  export async function deleteDailySummary(client) {
473
488
  return call(client, '/finances/email-schedules?kind=daily_summary', { method: 'DELETE' });
474
489
  }
package/src/index.d.ts CHANGED
@@ -93,6 +93,27 @@ export {
93
93
  invoiceProjectTime,
94
94
  } from './projects.js';
95
95
  export type { ArAging, AgingRow, InvoiceSettings, Project, ProjectStats, TimeEntry } from './projects.js';
96
+ export {
97
+ listContractors,
98
+ getContractor,
99
+ addContractor,
100
+ createContractorPortalLink,
101
+ offboardContractor,
102
+ createContract,
103
+ sendContract,
104
+ endContract,
105
+ listWorkItems,
106
+ reviewWorkItem,
107
+ addContractorPayment,
108
+ listPayRuns,
109
+ getPayRun,
110
+ createPayRun,
111
+ deletePayRun,
112
+ payPayRun,
113
+ recordPayRunPayments,
114
+ get1099Summary,
115
+ } from './contractors.js';
116
+ export type { Contractor, ContractorContract, ContractorMilestone, ContractorWorkItem, PayRun, PayRunItem, PayOutcome } from './contractors.js';
96
117
 
97
118
  // Webhook exports
98
119
  export {
package/src/index.js CHANGED
@@ -107,6 +107,27 @@ import {
107
107
  invoiceProjectTime,
108
108
  } from './projects.js';
109
109
 
110
+ import {
111
+ listContractors,
112
+ getContractor,
113
+ addContractor,
114
+ createContractorPortalLink,
115
+ offboardContractor,
116
+ createContract,
117
+ sendContract,
118
+ endContract,
119
+ listWorkItems,
120
+ reviewWorkItem,
121
+ addContractorPayment,
122
+ listPayRuns,
123
+ getPayRun,
124
+ createPayRun,
125
+ deletePayRun,
126
+ payPayRun,
127
+ recordPayRunPayments,
128
+ get1099Summary,
129
+ } from './contractors.js';
130
+
110
131
  // Wallet exports
111
132
  import {
112
133
  WalletClient,
@@ -273,6 +294,24 @@ export {
273
294
  stopTimer,
274
295
  listTimeEntries,
275
296
  invoiceProjectTime,
297
+ listContractors,
298
+ getContractor,
299
+ addContractor,
300
+ createContractorPortalLink,
301
+ offboardContractor,
302
+ createContract,
303
+ sendContract,
304
+ endContract,
305
+ listWorkItems,
306
+ reviewWorkItem,
307
+ addContractorPayment,
308
+ listPayRuns,
309
+ getPayRun,
310
+ createPayRun,
311
+ deletePayRun,
312
+ payPayRun,
313
+ recordPayRunPayments,
314
+ get1099Summary,
276
315
 
277
316
  // Wallet
278
317
  WalletClient,
package/src/mcp.js CHANGED
@@ -17,6 +17,7 @@ import { getFinanceSummary, listFinanceAccounts, listFinanceTransactions } from
17
17
  import * as sf from './statements-fetch.js';
18
18
  import { listInvoices, getInvoice, createInvoice } from './invoices.js';
19
19
  import * as projects from './projects.js';
20
+ import * as contractors from './contractors.js';
20
21
 
21
22
  export const PROTOCOL_VERSION = '2025-06-18';
22
23
 
@@ -94,13 +95,14 @@ export const TOOLS = [
94
95
  },
95
96
  {
96
97
  name: 'finance_daily_summary',
97
- description: 'The daily summary email (due soon, new activity, position): show it, turn it on (optionally hour 0-23 and recipients), turn it off, or send one now.',
98
+ description: 'The daily summary email (spend vs a $/day target, top cuts, due soon, new activity, position): show it, turn it on (optionally hour 0-23 and recipients), turn it off, send one now, or set_target (target in $/day, null clears).',
98
99
  inputSchema: {
99
100
  type: 'object',
100
101
  properties: {
101
- action: { type: 'string', enum: ['show', 'on', 'off', 'send_now'] },
102
+ action: { type: 'string', enum: ['show', 'on', 'off', 'send_now', 'set_target'] },
102
103
  hour: { type: 'integer', minimum: 0, maximum: 23 },
103
104
  to: { type: 'array', items: { type: 'string' }, maxItems: 5 },
105
+ target: { type: ['number', 'null'], minimum: 0 },
104
106
  },
105
107
  required: ['action'],
106
108
  additionalProperties: false,
@@ -109,7 +111,8 @@ export const TOOLS = [
109
111
  if (args.action === 'on') return text({ schedule: await reports.setDailySummary(client, { hour: args.hour, recipients: args.to, active: true }) });
110
112
  if (args.action === 'off') return text(await reports.deleteDailySummary(client));
111
113
  if (args.action === 'send_now') return text(await reports.sendDailySummaryNow(client));
112
- return text({ schedule: await reports.getDailySummary(client) });
114
+ if (args.action === 'set_target') return text(await reports.setDailySpendTarget(client, args.target ?? null));
115
+ return text(await reports.getDailySummaryWithTarget(client));
113
116
  },
114
117
  },
115
118
  {
@@ -321,6 +324,68 @@ export const TOOLS = [
321
324
  inputSchema: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'], additionalProperties: false },
322
325
  handler: async (args, { client }) => text(await projects.invoiceProjectTime(client, args.projectId)),
323
326
  },
327
+ // Contractors. None of these sends money: paying a run is a signed-in owner's act in the dashboard or CLI.
328
+ {
329
+ name: 'contractor_list',
330
+ description: 'List contractors with wallet and tax-form status and how many submissions wait for approval.',
331
+ inputSchema: { type: 'object', properties: { businessId: { type: 'string' }, includeOffboarded: { type: 'boolean' } }, additionalProperties: false },
332
+ handler: async (args, { client }) => text({ contractors: await contractors.listContractors(client, args) }),
333
+ },
334
+ {
335
+ name: 'contractor_get',
336
+ description: 'One contractor: contracts and milestones, work lines and payments.',
337
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'], additionalProperties: false },
338
+ handler: async (args, { client }) => text(await contractors.getContractor(client, args.id)),
339
+ },
340
+ {
341
+ name: 'contractor_work_list',
342
+ description: 'Contractor work lines (hours, milestones, expenses, bonuses, fixed fees). Use status "submitted" for what waits for approval.',
343
+ inputSchema: {
344
+ type: 'object',
345
+ properties: {
346
+ businessId: { type: 'string' },
347
+ status: { type: 'string', enum: ['submitted', 'approved', 'rejected', 'paid'] },
348
+ contractorId: { type: 'string' },
349
+ },
350
+ additionalProperties: false,
351
+ },
352
+ handler: async (args, { client }) => text({ work_items: await contractors.listWorkItems(client, args) }),
353
+ },
354
+ {
355
+ name: 'contractor_work_review',
356
+ description: 'Approve or reject a submitted work line. A rejection needs a note the contractor will see. Approving does not pay; it queues the line for the next pay run.',
357
+ inputSchema: {
358
+ type: 'object',
359
+ properties: { id: { type: 'string' }, action: { type: 'string', enum: ['approve', 'reject'] }, note: { type: 'string' } },
360
+ required: ['id', 'action'],
361
+ additionalProperties: false,
362
+ },
363
+ handler: async (args, { client }) => text({ work_item: await contractors.reviewWorkItem(client, args.id, args) }),
364
+ },
365
+ {
366
+ name: 'pay_run_list',
367
+ description: 'Pay runs with totals and how many contractors are paid.',
368
+ inputSchema: { type: 'object', properties: { businessId: { type: 'string' } }, additionalProperties: false },
369
+ handler: async (args, { client }) => text({ pay_runs: await contractors.listPayRuns(client, args) }),
370
+ },
371
+ {
372
+ name: 'pay_run_get',
373
+ description: 'One pay run: per-contractor amounts, lines, status and warnings (missing or recently changed wallets).',
374
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'], additionalProperties: false },
375
+ handler: async (args, { client }) => text(await contractors.getPayRun(client, args.id)),
376
+ },
377
+ {
378
+ name: 'pay_run_draft',
379
+ description: 'Draft a pay run from fixed fees due by payDate (YYYY-MM-DD, default today) plus all approved work. Sends nothing; the owner reviews and pays it.',
380
+ inputSchema: { type: 'object', properties: { businessId: { type: 'string' }, payDate: { type: 'string' } }, additionalProperties: false },
381
+ handler: async (args, { client }) => text({ pay_run: await contractors.createPayRun(client, args) }),
382
+ },
383
+ {
384
+ name: 'contractor_1099_summary',
385
+ description: 'Totals paid per contractor in a calendar year and who needs a 1099-NEC (US, $600+, not a corporation). Shows only the last four of tax ids.',
386
+ inputSchema: { type: 'object', properties: { businessId: { type: 'string' }, year: { type: 'integer' } }, additionalProperties: false },
387
+ handler: async (args, { client }) => text(await contractors.get1099Summary(client, args)),
388
+ },
324
389
  ];
325
390
 
326
391
  /** Answer one JSON-RPC message; null for a notification. */