@profullstack/coinpay 0.17.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 +25 -0
- package/bin/coinpay.js +263 -2
- package/package.json +1 -1
- package/src/index.d.ts +18 -0
- package/src/index.js +30 -0
- package/src/mcp.js +103 -0
- package/src/projects.d.ts +91 -0
- package/src/projects.js +106 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ 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
|
+
|
|
8
33
|
## [0.17.0] - 2026-10-17
|
|
9
34
|
|
|
10
35
|
### Added
|
package/bin/coinpay.js
CHANGED
|
@@ -31,6 +31,21 @@ 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';
|
|
34
49
|
import { readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from 'fs';
|
|
35
50
|
import { execSync, spawn } from 'child_process';
|
|
36
51
|
import { createInterface } from 'readline';
|
|
@@ -173,6 +188,11 @@ function createUnauthenticatedClient() {
|
|
|
173
188
|
}
|
|
174
189
|
|
|
175
190
|
const BOOLEAN_FLAGS = new Set([
|
|
191
|
+
// projects, time, client portal
|
|
192
|
+
'non-billable',
|
|
193
|
+
'unbilled',
|
|
194
|
+
'archived',
|
|
195
|
+
'revoke',
|
|
176
196
|
'active',
|
|
177
197
|
// finances reports / statements
|
|
178
198
|
'wait',
|
|
@@ -237,7 +257,7 @@ function parseArgs(args) {
|
|
|
237
257
|
const next = args[i + 1];
|
|
238
258
|
if (BOOLEAN_FLAGS.has(name)) {
|
|
239
259
|
result.flags[name] = true;
|
|
240
|
-
} else if (next && !next.startsWith('-')) {
|
|
260
|
+
} else if (next && (!next.startsWith('-') || /^-\d/.test(next))) {
|
|
241
261
|
setFlag(name, next);
|
|
242
262
|
i++;
|
|
243
263
|
} else {
|
|
@@ -471,6 +491,27 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
471
491
|
delete <id> Permanently delete a draft invoice
|
|
472
492
|
--yes Skip confirmation for publish/send/delete
|
|
473
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
|
+
|
|
474
515
|
${colors.bright}item${colors.reset}
|
|
475
516
|
list Saved products/services (--business-id)
|
|
476
517
|
create Save one: --name, --price [--unit hour] [--description]
|
|
@@ -1164,6 +1205,141 @@ function printInvoiceDetails(invoice, { shareUrl } = {}) {
|
|
|
1164
1205
|
/**
|
|
1165
1206
|
* Invoice commands
|
|
1166
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
|
+
|
|
1167
1343
|
async function handleCatalog(kind, subcommand, args, flags) {
|
|
1168
1344
|
const client = createClient();
|
|
1169
1345
|
const isItem = kind === 'item';
|
|
@@ -1686,9 +1862,82 @@ async function handleInvoice(subcommand, args, flags) {
|
|
|
1686
1862
|
break;
|
|
1687
1863
|
}
|
|
1688
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
|
+
|
|
1689
1938
|
default:
|
|
1690
1939
|
print.error('Unknown invoice command: ' + subcommand);
|
|
1691
|
-
print.info('Available: create, list, get, update, publish, send, delete');
|
|
1940
|
+
print.info('Available: create, list, get, update, publish, send, delete, aging, settings');
|
|
1692
1941
|
process.exit(1);
|
|
1693
1942
|
}
|
|
1694
1943
|
}
|
|
@@ -4764,6 +5013,18 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
4764
5013
|
await handleInvoice(subcommand, args, flags);
|
|
4765
5014
|
break;
|
|
4766
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
|
+
|
|
4767
5028
|
case 'item':
|
|
4768
5029
|
case 'tax-rate':
|
|
4769
5030
|
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.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
|
@@ -76,6 +76,24 @@ export type {
|
|
|
76
76
|
ListInvoicesResult,
|
|
77
77
|
} from './invoices.js';
|
|
78
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
|
+
|
|
79
97
|
// Webhook exports
|
|
80
98
|
export {
|
|
81
99
|
verifyWebhookSignature,
|
package/src/index.js
CHANGED
|
@@ -90,6 +90,23 @@ import {
|
|
|
90
90
|
deleteInvoiceItem,
|
|
91
91
|
} from './invoices.js';
|
|
92
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
|
+
|
|
93
110
|
// Wallet exports
|
|
94
111
|
import {
|
|
95
112
|
WalletClient,
|
|
@@ -243,6 +260,19 @@ export {
|
|
|
243
260
|
listInvoiceItems,
|
|
244
261
|
createInvoiceItem,
|
|
245
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,
|
|
246
276
|
|
|
247
277
|
// Wallet
|
|
248
278
|
WalletClient,
|
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
|
+
}
|