@opencard-dev/cli 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1272 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * OpenCard CLI: Command-line interface for setup and management
5
+ *
6
+ * Commands:
7
+ * opencard serve Start webhook server with public tunnel
8
+ * opencard setup Interactive setup wizard
9
+ * opencard status Show connection & system status
10
+ * opencard cards list List all active cards
11
+ * opencard rules list List all spend rules
12
+ * opencard rules add Interactive rule creation
13
+ * opencard test Test rules with mock transactions
14
+ * opencard approvals list View pending approval requests
15
+ * opencard version Show version
16
+ */
17
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ var desc = Object.getOwnPropertyDescriptor(m, k);
20
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
21
+ desc = { enumerable: true, get: function() { return m[k]; } };
22
+ }
23
+ Object.defineProperty(o, k2, desc);
24
+ }) : (function(o, m, k, k2) {
25
+ if (k2 === undefined) k2 = k;
26
+ o[k2] = m[k];
27
+ }));
28
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
29
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
30
+ }) : function(o, v) {
31
+ o["default"] = v;
32
+ });
33
+ var __importStar = (this && this.__importStar) || (function () {
34
+ var ownKeys = function(o) {
35
+ ownKeys = Object.getOwnPropertyNames || function (o) {
36
+ var ar = [];
37
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
38
+ return ar;
39
+ };
40
+ return ownKeys(o);
41
+ };
42
+ return function (mod) {
43
+ if (mod && mod.__esModule) return mod;
44
+ var result = {};
45
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
46
+ __setModuleDefault(result, mod);
47
+ return result;
48
+ };
49
+ })();
50
+ var __importDefault = (this && this.__importDefault) || function (mod) {
51
+ return (mod && mod.__esModule) ? mod : { "default": mod };
52
+ };
53
+ Object.defineProperty(exports, "__esModule", { value: true });
54
+ const commander_1 = require("commander");
55
+ const stripe_1 = __importDefault(require("stripe"));
56
+ const core_1 = require("@opencard-dev/core");
57
+ const webhook_server_1 = require("@opencard-dev/webhook-server");
58
+ const table_1 = require("table");
59
+ const readline = __importStar(require("readline"));
60
+ const localtunnel_1 = __importDefault(require("localtunnel"));
61
+ // ─── Dashboard Rendering Helpers ──────────────────────────────────────────
62
+ const BOX_WIDTH = 55; // inner width of the box (between │ characters)
63
+ function useColor() {
64
+ return process.stdout.isTTY === true && !outputJson;
65
+ }
66
+ function colorize(text, color) {
67
+ if (!useColor())
68
+ return text;
69
+ return `${color}${text}\x1b[0m`;
70
+ }
71
+ function progressBar(filled, total) {
72
+ const BAR_WIDTH = 28;
73
+ const pct = total > 0 ? Math.min(filled / total, 1) : 0;
74
+ const filledChars = Math.round(pct * BAR_WIDTH);
75
+ const emptyChars = BAR_WIDTH - filledChars;
76
+ const bar = '█'.repeat(filledChars) + '░'.repeat(emptyChars);
77
+ if (!useColor())
78
+ return bar;
79
+ let colorCode;
80
+ if (pct > 0.85) {
81
+ colorCode = '\x1b[31m'; // red
82
+ }
83
+ else if (pct >= 0.60) {
84
+ colorCode = '\x1b[33m'; // yellow
85
+ }
86
+ else {
87
+ colorCode = '\x1b[32m'; // green
88
+ }
89
+ return `${colorCode}${bar}\x1b[0m`;
90
+ }
91
+ function formatMoney(cents) {
92
+ return `$${(cents / 100).toFixed(2)}`;
93
+ }
94
+ function formatTimeAgo(dateStr) {
95
+ const date = new Date(dateStr);
96
+ const now = new Date();
97
+ const diffMs = now.getTime() - date.getTime();
98
+ const diffMinutes = Math.floor(diffMs / 60000);
99
+ const diffHours = Math.floor(diffMs / 3600000);
100
+ if (diffMinutes < 60) {
101
+ return `${diffMinutes} minutes ago`;
102
+ }
103
+ else if (diffHours < 24) {
104
+ return `${diffHours} hours ago`;
105
+ }
106
+ else if (diffHours < 48) {
107
+ // Check if it was actually yesterday (calendar day)
108
+ const yesterday = new Date(now);
109
+ yesterday.setUTCDate(yesterday.getUTCDate() - 1);
110
+ if (date.toISOString().slice(0, 10) === yesterday.toISOString().slice(0, 10)) {
111
+ return 'yesterday';
112
+ }
113
+ }
114
+ // Older: "Mar 15"
115
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
116
+ }
117
+ /** Pad/truncate a string to exactly `width` chars */
118
+ function pad(str, width) {
119
+ if (str.length > width)
120
+ return str.slice(0, width);
121
+ return str + ' '.repeat(width - str.length);
122
+ }
123
+ /** Build a single box line: "│ content │" padded to BOX_WIDTH */
124
+ function boxLine(content) {
125
+ // Strip ANSI codes for length calculation
126
+ const stripped = content.replace(/\x1b\[[0-9;]*m/g, '');
127
+ const padding = BOX_WIDTH - stripped.length - 2; // 2 for leading spaces
128
+ return `│ ${content}${' '.repeat(Math.max(0, padding))} │`;
129
+ }
130
+ function boxEmpty() {
131
+ return `│${''.padEnd(BOX_WIDTH + 2)}│`;
132
+ }
133
+ function boxTop() {
134
+ return `┌${'─'.repeat(BOX_WIDTH + 2)}┐`;
135
+ }
136
+ function boxBottom() {
137
+ return `└${'─'.repeat(BOX_WIDTH + 2)}┘`;
138
+ }
139
+ function boxDivider() {
140
+ return `├${'─'.repeat(BOX_WIDTH + 2)}┤`;
141
+ }
142
+ function renderDashboard(result) {
143
+ const INDENT = ' ';
144
+ console.log();
145
+ // Header
146
+ const stripeStatus = result.stripeConnected ? '✓' : '✗';
147
+ console.log(`${INDENT}OpenCard v${result.version} — ${result.stripeConnected ? 'connected to Stripe' : 'not connected to Stripe'} ${stripeStatus}`);
148
+ if (result.webhookRunning) {
149
+ const uptimeStr = result.webhookUptime ? ` (uptime ${result.webhookUptime})` : '';
150
+ console.log(`${INDENT}Webhook server: running on :${result.webhookPort}${uptimeStr}`);
151
+ }
152
+ else {
153
+ console.log(`${INDENT}Webhook server: not running`);
154
+ }
155
+ if (!result.dbAvailable) {
156
+ console.log(`${INDENT}⚠ Run webhook server to enable spend tracking`);
157
+ }
158
+ console.log();
159
+ if (result.cards.length === 0) {
160
+ console.log(`${INDENT}No cards found. Run \`opencard setup\` to create your first card.`);
161
+ console.log();
162
+ return;
163
+ }
164
+ // Card boxes
165
+ console.log(`${INDENT}${boxTop()}`);
166
+ for (let i = 0; i < result.cards.length; i++) {
167
+ const card = result.cards[i];
168
+ if (i > 0) {
169
+ console.log(`${INDENT}${boxDivider()}`);
170
+ }
171
+ // Card header: 💳 Name ···last4 (status)
172
+ const cardHeader = `💳 ${card.cardholderName || 'Unknown'} ···${card.last4} (${card.status})`;
173
+ console.log(`${INDENT}${boxLine(cardHeader)}`);
174
+ // Description line (if set)
175
+ if (card.description) {
176
+ console.log(`${INDENT}${boxLine(`${card.description}`)}`);
177
+ }
178
+ // Rule line
179
+ const ruleDisplay = card.ruleName || (card.ruleId ? card.ruleId : 'No rule assigned');
180
+ console.log(`${INDENT}${boxLine(`Rule: ${ruleDisplay}`)}`);
181
+ // Spend section
182
+ console.log(`${INDENT}${boxEmpty()}`);
183
+ if (card.spend === null) {
184
+ // DB not available
185
+ console.log(`${INDENT}${boxLine('Spend data unavailable')}`);
186
+ }
187
+ else {
188
+ const rule = card.rule;
189
+ // Today
190
+ const todaySpend = card.spend.today;
191
+ const dailyLimit = rule?.dailyLimit ?? null;
192
+ if (dailyLimit !== null) {
193
+ const todayPct = Math.round((todaySpend / dailyLimit) * 100);
194
+ console.log(`${INDENT}${boxLine(`Today ${formatMoney(todaySpend)} / ${formatMoney(dailyLimit)}`)}`);
195
+ console.log(`${INDENT}${boxLine(`${progressBar(todaySpend, dailyLimit)} ${todayPct}%`)}`);
196
+ }
197
+ else {
198
+ console.log(`${INDENT}${boxLine(`Today ${formatMoney(todaySpend)}`)}`);
199
+ }
200
+ console.log(`${INDENT}${boxEmpty()}`);
201
+ // This Month
202
+ const monthSpend = card.spend.thisMonth;
203
+ const monthlyLimit = rule?.monthlyLimit ?? null;
204
+ if (monthlyLimit !== null) {
205
+ const monthPct = Math.round((monthSpend / monthlyLimit) * 100);
206
+ console.log(`${INDENT}${boxLine(`This Month ${formatMoney(monthSpend)} / ${formatMoney(monthlyLimit)}`)}`);
207
+ console.log(`${INDENT}${boxLine(`${progressBar(monthSpend, monthlyLimit)} ${monthPct}%`)}`);
208
+ }
209
+ else {
210
+ console.log(`${INDENT}${boxLine(`This Month ${formatMoney(monthSpend)}`)}`);
211
+ }
212
+ }
213
+ // Recent transactions
214
+ console.log(`${INDENT}${boxEmpty()}`);
215
+ console.log(`${INDENT}${boxLine('Last 3 transactions:')}`);
216
+ if (card.recentTransactions.length === 0) {
217
+ console.log(`${INDENT}${boxLine(' No transactions yet')}`);
218
+ }
219
+ else {
220
+ for (const txn of card.recentTransactions) {
221
+ const amt = pad(formatMoney(txn.amount), 8);
222
+ const merchant = pad(txn.merchantName || 'Unknown', 20);
223
+ const when = formatTimeAgo(txn.recordedAt);
224
+ console.log(`${INDENT}${boxLine(` ${amt} ${merchant} ${when}`)}`);
225
+ }
226
+ }
227
+ }
228
+ console.log(`${INDENT}${boxBottom()}`);
229
+ console.log();
230
+ // Footer
231
+ const s = result.summary;
232
+ console.log(`${INDENT}${s.activeCards} active card${s.activeCards !== 1 ? 's' : ''} · ${s.declinedToday} declined today · ${s.transactionsThisWeek} transactions this week`);
233
+ console.log();
234
+ }
235
+ // ─── Configuration ────────────────────────────────────────────────────────
236
+ const VERSION = '0.1.0';
237
+ const RULES_STORE_PATH = process.env.RULES_STORE_PATH || './opencard-rules.json';
238
+ // Global output flag
239
+ let outputJson = false;
240
+ // ─── CLI Setup ────────────────────────────────────────────────────────────
241
+ commander_1.program
242
+ .name('opencard')
243
+ .description('OpenCard CLI for Stripe Issuing automation')
244
+ .version(VERSION)
245
+ .option('--json', 'Output JSON instead of formatted text')
246
+ .addHelpText('after', `
247
+
248
+ Quick Start Examples:
249
+
250
+ # Set up your first card (interactive wizard)
251
+ opencard setup
252
+
253
+ # Check status of all cards and spend
254
+ opencard status
255
+
256
+ # List all active cards
257
+ opencard cards list
258
+ opencard cards list --json
259
+
260
+ # Manage spend rules
261
+ opencard rules list
262
+ opencard rules add
263
+
264
+ # Test your rules with example transactions
265
+ opencard test
266
+
267
+ # Human-in-the-loop approvals
268
+ opencard approvals list
269
+ opencard approvals approve <id>
270
+ opencard approvals deny <id> --note "reason"
271
+
272
+ Documentation: https://github.com/JLongshot/opencard`);
273
+ // ─── Commands ──────────────────────────────────────────────────────────────
274
+ /**
275
+ * opencard setup
276
+ * Interactive setup wizard for initial configuration
277
+ */
278
+ commander_1.program
279
+ .command('setup')
280
+ .description('Interactive setup wizard')
281
+ .action(async () => {
282
+ try {
283
+ await runSetup();
284
+ }
285
+ catch (error) {
286
+ const message = error instanceof Error ? error.message : String(error);
287
+ console.error(message);
288
+ process.exit(1);
289
+ }
290
+ });
291
+ /**
292
+ * opencard status
293
+ * Show rich dashboard: cards, spend vs limits, recent transactions, webhook health
294
+ */
295
+ commander_1.program
296
+ .command('status')
297
+ .description('Show rich dashboard with card spend, limits, and recent transactions')
298
+ .action(async (options, cmd) => {
299
+ outputJson = cmd.parent?.opts()?.json || false;
300
+ try {
301
+ const result = await getRichStatus();
302
+ if (outputJson) {
303
+ console.log(JSON.stringify(result, null, 2));
304
+ }
305
+ else {
306
+ renderDashboard(result);
307
+ }
308
+ }
309
+ catch (error) {
310
+ const message = error instanceof Error ? error.message : String(error);
311
+ console.error(message);
312
+ process.exit(1);
313
+ }
314
+ });
315
+ /**
316
+ * opencard cards list
317
+ * Show all active cards in table format
318
+ */
319
+ const cardsCommand = commander_1.program
320
+ .command('cards')
321
+ .description('Manage cards')
322
+ .addHelpText('after', `
323
+
324
+ Examples:
325
+
326
+ opencard cards list # Show all active cards in table format
327
+ opencard cards list --json # Show all cards as JSON
328
+ `);
329
+ cardsCommand
330
+ .command('list')
331
+ .description('List active cards')
332
+ .option('--json', 'Output as JSON')
333
+ .action(async (options, cmd) => {
334
+ outputJson = cmd.parent?.parent?.opts()?.json || options.json || false;
335
+ try {
336
+ const cards = await getCards();
337
+ if (outputJson) {
338
+ console.log(JSON.stringify({ cards }, null, 2));
339
+ }
340
+ else {
341
+ if (cards.length === 0) {
342
+ console.log('No active cards found.');
343
+ return;
344
+ }
345
+ const tableData = [
346
+ ['Card ID', 'Last 4', 'Cardholder', 'Description', 'Status', 'Created'],
347
+ ...cards.map(card => [
348
+ card.id.substring(0, 12) + '...',
349
+ card.last4,
350
+ card.cardholder_name || '—',
351
+ card.description || '—',
352
+ card.status,
353
+ new Date(card.created * 1000).toLocaleDateString(),
354
+ ]),
355
+ ];
356
+ console.log('\n💳 Active Cards');
357
+ console.log((0, table_1.table)(tableData, { border: getBorderChars() }));
358
+ }
359
+ }
360
+ catch (error) {
361
+ const message = error instanceof Error ? error.message : String(error);
362
+ console.error(message);
363
+ process.exit(1);
364
+ }
365
+ });
366
+ /**
367
+ * opencard rules list
368
+ * Show all spend rules in table format
369
+ */
370
+ const rulesCommand = commander_1.program
371
+ .command('rules')
372
+ .description('Manage spend rules')
373
+ .addHelpText('after', `
374
+
375
+ Examples:
376
+
377
+ opencard rules list # Show all configured rules
378
+ opencard rules add # Interactively create a new rule
379
+ `);
380
+ rulesCommand
381
+ .command('list')
382
+ .description('List all configured spend rules')
383
+ .option('--json', 'Output as JSON')
384
+ .action(async (options, cmd) => {
385
+ outputJson = cmd.parent?.parent?.opts()?.json || options.json || false;
386
+ try {
387
+ const rules = await getRules();
388
+ if (outputJson) {
389
+ console.log(JSON.stringify({ rules }, null, 2));
390
+ }
391
+ else {
392
+ if (rules.length === 0) {
393
+ console.log('No rules configured.');
394
+ return;
395
+ }
396
+ const tableData = [
397
+ ['Rule ID', 'Max Per Txn', 'Daily Limit', 'Monthly Limit', 'On Failure'],
398
+ ...rules.map(ruleItem => {
399
+ const rule = ruleItem.rule;
400
+ const maxPerTxn = rule.maxPerTransaction
401
+ ? `$${(rule.maxPerTransaction / 100).toFixed(2)}`
402
+ : '—';
403
+ const daily = rule.dailyLimit
404
+ ? `$${(rule.dailyLimit / 100).toFixed(2)}`
405
+ : '—';
406
+ const monthly = rule.monthlyLimit
407
+ ? `$${(rule.monthlyLimit / 100).toFixed(2)}`
408
+ : '—';
409
+ return [
410
+ ruleItem.id.substring(0, 12) + '...',
411
+ maxPerTxn,
412
+ daily,
413
+ monthly,
414
+ rule.onFailure || 'decline',
415
+ ];
416
+ }),
417
+ ];
418
+ console.log('\n📋 Spend Rules');
419
+ console.log((0, table_1.table)(tableData, { border: getBorderChars() }));
420
+ }
421
+ }
422
+ catch (error) {
423
+ const message = error instanceof Error ? error.message : String(error);
424
+ console.error(message);
425
+ process.exit(1);
426
+ }
427
+ });
428
+ /**
429
+ * opencard rules add
430
+ * Interactive rule creation
431
+ */
432
+ rulesCommand
433
+ .command('add')
434
+ .description('Create a new spend rule (interactive)')
435
+ .action(async (options, cmd) => {
436
+ try {
437
+ await createRule();
438
+ }
439
+ catch (error) {
440
+ const message = error instanceof Error ? error.message : String(error);
441
+ console.error(message);
442
+ process.exit(1);
443
+ }
444
+ });
445
+ /**
446
+ * opencard test
447
+ * Simulate 3 authorization requests against the user's configured rules.
448
+ * No real Stripe calls, no real money — validates the decision engine end-to-end.
449
+ */
450
+ commander_1.program
451
+ .command('test')
452
+ .description('Simulate authorization requests against your rules (no real charges)')
453
+ .action(async (options, cmd) => {
454
+ outputJson = cmd.parent?.opts()?.json || false;
455
+ try {
456
+ await runTest();
457
+ }
458
+ catch (error) {
459
+ const message = error instanceof Error ? error.message : String(error);
460
+ console.error(message);
461
+ process.exit(1);
462
+ }
463
+ });
464
+ /**
465
+ * opencard serve
466
+ * Start webhook server with public tunnel and Stripe webhook registration
467
+ */
468
+ commander_1.program
469
+ .command('serve')
470
+ .description('Start webhook server with public tunnel and Stripe webhook registration')
471
+ .option('--port <port>', 'Webhook server port (default: 3000)', '3000')
472
+ .option('--no-tunnel', 'Skip localtunnel and use --url instead')
473
+ .option('--url <url>', 'Public webhook URL (use with --no-tunnel)')
474
+ .action(async (options) => {
475
+ try {
476
+ await runServe(parseInt(options.port, 10), options.tunnel, options.url);
477
+ }
478
+ catch (error) {
479
+ const message = error instanceof Error ? error.message : String(error);
480
+ console.error(message);
481
+ process.exit(1);
482
+ }
483
+ });
484
+ /**
485
+ * opencard version
486
+ * Show version
487
+ */
488
+ commander_1.program
489
+ .command('version')
490
+ .description('Show version')
491
+ .action(() => {
492
+ console.log(`OpenCard CLI v${VERSION}`);
493
+ });
494
+ // ─── Implementation ───────────────────────────────────────────────────────
495
+ /**
496
+ * Collect all data needed for the rich status dashboard.
497
+ */
498
+ async function getRichStatus() {
499
+ const stripeKey = process.env.STRIPE_SECRET_KEY;
500
+ const webhookPort = parseInt(process.env.WEBHOOK_PORT || '3000', 10);
501
+ const version = VERSION;
502
+ const stripeConnected = !!stripeKey;
503
+ const webhookRunning = await checkWebhookHealth(webhookPort);
504
+ // Try to get webhook uptime from health endpoint
505
+ let webhookUptime = null;
506
+ if (webhookRunning) {
507
+ try {
508
+ const controller = new AbortController();
509
+ const timeout = setTimeout(() => controller.abort(), 2000);
510
+ const response = await fetch(`http://localhost:${webhookPort}/health`, { signal: controller.signal });
511
+ clearTimeout(timeout);
512
+ if (response.ok) {
513
+ const data = await response.json();
514
+ if (typeof data.uptime === 'number') {
515
+ webhookUptime = formatUptime(data.uptime);
516
+ }
517
+ }
518
+ }
519
+ catch {
520
+ // ignore
521
+ }
522
+ }
523
+ // Try to open DB
524
+ let db = null;
525
+ let dbAvailable = false;
526
+ try {
527
+ db = new core_1.OpenCardDatabase();
528
+ dbAvailable = true;
529
+ }
530
+ catch {
531
+ dbAvailable = false;
532
+ }
533
+ // Load rules store
534
+ const store = new core_1.RulesStore(RULES_STORE_PATH);
535
+ let rulesMap = new Map();
536
+ try {
537
+ const rulesList = await store.listRules();
538
+ for (const entry of rulesList) {
539
+ rulesMap.set(entry.id, entry);
540
+ }
541
+ }
542
+ catch {
543
+ // rules not available
544
+ }
545
+ // Load cards from Stripe
546
+ let stripeCards = [];
547
+ if (stripeKey) {
548
+ try {
549
+ const stripe = new stripe_1.default(stripeKey, { apiVersion: '2023-10-16' });
550
+ const response = await stripe.issuing.cards.list({ limit: 100 });
551
+ stripeCards = response.data.map(card => ({
552
+ id: card.id,
553
+ last4: card.last4,
554
+ cardholderName: typeof card.cardholder === 'object' && card.cardholder !== null
555
+ ? card.cardholder.name || null
556
+ : null,
557
+ description: card.metadata?.opencard_description ?? null,
558
+ status: card.status,
559
+ ruleId: card.metadata?.opencard_rule_id || null,
560
+ }));
561
+ }
562
+ catch {
563
+ // Stripe unavailable
564
+ }
565
+ }
566
+ // Build per-card data
567
+ const cards = [];
568
+ // Summary accumulators
569
+ let declinedToday = 0;
570
+ let transactionsThisWeek = 0;
571
+ // Get today midnight UTC and week start
572
+ const now = new Date();
573
+ const todayMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
574
+ const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
575
+ const weekAgo = new Date(todayMidnight.getTime() - 7 * 24 * 60 * 60 * 1000);
576
+ // Compute global summary stats from DB if available
577
+ if (db) {
578
+ try {
579
+ const rawDb = db.getDb();
580
+ // Declined today
581
+ const declinedRow = rawDb.prepare(`SELECT COUNT(*) as cnt FROM authorizations WHERE status = 'declined' AND created_at >= ?`).get(todayMidnight.toISOString().replace('T', ' ').slice(0, 19));
582
+ declinedToday = declinedRow.cnt;
583
+ // Transactions this week
584
+ const txnRow = rawDb.prepare(`SELECT COUNT(*) as cnt FROM transactions WHERE recorded_at >= ?`).get(weekAgo.toISOString().replace('T', ' ').slice(0, 19));
585
+ transactionsThisWeek = txnRow.cnt;
586
+ }
587
+ catch {
588
+ // ignore
589
+ }
590
+ }
591
+ // Build each card's rich status
592
+ for (const stripeCard of stripeCards) {
593
+ const ruleEntry = stripeCard.ruleId ? rulesMap.get(stripeCard.ruleId) || null : null;
594
+ const rule = ruleEntry?.rule || null;
595
+ const ruleName = rule?.name || (stripeCard.ruleId ? stripeCard.ruleId : null);
596
+ let spend = null;
597
+ let recentTransactions = [];
598
+ if (db) {
599
+ try {
600
+ const rawDb = db.getDb();
601
+ // Spend from authorizations table with status='approved' for real-time budget enforcement.
602
+ // We use authorizations (not transactions) because:
603
+ // - authorizations are real-time and status reflects rule enforcement decisions
604
+ // - transactions are a subset of authorizations that were captured by Stripe
605
+ // - using the same table ensures budget enforcement and reporting show the same spend totals
606
+ // - this matches MCP get_balance and list_cards for consistency
607
+ // Today's spend (approved authorizations since midnight UTC)
608
+ const todayRow = rawDb.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM authorizations WHERE card_id = ? AND status = 'approved' AND created_at >= ?`).get(stripeCard.id, todayMidnight.toISOString().replace('T', ' ').slice(0, 19));
609
+ // This month's spend (approved authorizations since 1st of month UTC)
610
+ const monthRow = rawDb.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM authorizations WHERE card_id = ? AND status = 'approved' AND created_at >= ?`).get(stripeCard.id, monthStart.toISOString().replace('T', ' ').slice(0, 19));
611
+ spend = {
612
+ today: todayRow.total,
613
+ thisMonth: monthRow.total,
614
+ };
615
+ // Last 3 transactions
616
+ const txnRows = rawDb.prepare(`SELECT amount, merchant_name, recorded_at FROM transactions WHERE card_id = ? ORDER BY recorded_at DESC LIMIT 3`).all(stripeCard.id);
617
+ recentTransactions = txnRows.map(row => ({
618
+ amount: row.amount,
619
+ merchantName: row.merchant_name,
620
+ recordedAt: row.recorded_at,
621
+ }));
622
+ }
623
+ catch {
624
+ // DB queries failed — leave spend as null
625
+ }
626
+ }
627
+ cards.push({
628
+ id: stripeCard.id,
629
+ last4: stripeCard.last4,
630
+ cardholderName: stripeCard.cardholderName,
631
+ description: stripeCard.description,
632
+ status: stripeCard.status,
633
+ ruleId: stripeCard.ruleId,
634
+ ruleName,
635
+ rule,
636
+ spend,
637
+ recentTransactions,
638
+ });
639
+ }
640
+ const activeCards = cards.filter(c => c.status === 'active').length;
641
+ if (db) {
642
+ db.close();
643
+ }
644
+ return {
645
+ version,
646
+ stripeConnected,
647
+ webhookRunning,
648
+ webhookPort,
649
+ webhookUptime,
650
+ dbAvailable,
651
+ cards,
652
+ summary: {
653
+ activeCards,
654
+ declinedToday,
655
+ transactionsThisWeek,
656
+ },
657
+ };
658
+ }
659
+ function formatUptime(seconds) {
660
+ const h = Math.floor(seconds / 3600);
661
+ const m = Math.floor((seconds % 3600) / 60);
662
+ if (h > 0)
663
+ return `${h}h ${m}m`;
664
+ return `${m}m`;
665
+ }
666
+ async function runSetup() {
667
+ console.log('\n🎯 OpenCard Setup Wizard');
668
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
669
+ // Check Stripe key
670
+ const stripeKey = process.env.STRIPE_SECRET_KEY;
671
+ if (!stripeKey) {
672
+ console.log('❌ Error: STRIPE_SECRET_KEY not set');
673
+ console.log('\nSet your key with: export STRIPE_SECRET_KEY=sk_test_...');
674
+ process.exit(1);
675
+ }
676
+ console.log('✓ STRIPE_SECRET_KEY configured');
677
+ // Initialize client
678
+ let client;
679
+ try {
680
+ client = new core_1.StripeClient({ secretKey: stripeKey });
681
+ console.log('✓ Connected to Stripe');
682
+ }
683
+ catch (error) {
684
+ console.error('✗ Failed to initialize Stripe client:', error);
685
+ process.exit(1);
686
+ }
687
+ // Create a test cardholder
688
+ console.log('\n📋 Creating test cardholder...');
689
+ try {
690
+ const cardholder = await client.createCardholder('Test Agent', 'test@example.com');
691
+ console.log(`✓ Cardholder created: ${cardholder.id}`);
692
+ console.log(` Name: ${cardholder.name}`);
693
+ console.log(` Email: ${cardholder.email}`);
694
+ // Create a rule in the rules store (best practice — keeps rules out of Stripe metadata)
695
+ console.log('\n📋 Creating spend rule...');
696
+ const store = new core_1.RulesStore(RULES_STORE_PATH);
697
+ const rule = core_1.SpendRules.template('daily_limit').build();
698
+ const ruleId = await store.createRule(rule);
699
+ console.log(`✓ Rule created: ${ruleId}`);
700
+ console.log(` Daily limit: $${((rule.dailyLimit || 0) / 100).toFixed(2)}`);
701
+ // Create a test card linked to the rule via ruleId
702
+ console.log('\n💳 Creating test card...');
703
+ const card = await client.createCard(cardholder.id, {
704
+ agentName: 'test-agent',
705
+ description: 'Test card created by opencard setup',
706
+ ruleId,
707
+ });
708
+ console.log(`✓ Card created: ${card.last4}`);
709
+ console.log(` ID: ${card.id}`);
710
+ console.log(` Expires: ${card.expiry}`);
711
+ console.log(` Status: ${card.status}`);
712
+ // Get card details
713
+ console.log('\n🔍 Fetching card details...');
714
+ const cardDetails = await client.getCard(card.id);
715
+ console.log(`✓ Card retrieved successfully`);
716
+ console.log(` Last 4: ${cardDetails.last4}`);
717
+ console.log(` Agent: ${cardDetails.agentName}`);
718
+ console.log('\n✅ Setup complete!');
719
+ console.log('\nNext steps:');
720
+ console.log(' 1. Review the documentation: https://github.com/JLongshot/opencard');
721
+ console.log(' 2. Integrate with your agent using the @opencard/core SDK');
722
+ console.log(' 3. Test with more cards and rules\n');
723
+ }
724
+ catch (error) {
725
+ console.error('✗ Setup failed:', error);
726
+ process.exit(1);
727
+ }
728
+ }
729
+ async function getCards() {
730
+ const stripeKey = process.env.STRIPE_SECRET_KEY;
731
+ if (!stripeKey) {
732
+ throw new Error('STRIPE_SECRET_KEY not set. Get your test key from https://dashboard.stripe.com/apikeys');
733
+ }
734
+ if (!stripeKey.startsWith('sk_test_') && !stripeKey.startsWith('sk_live_')) {
735
+ console.warn('STRIPE_SECRET_KEY looks invalid — test keys start with sk_test_, live keys with sk_live_');
736
+ }
737
+ const stripe = new stripe_1.default(stripeKey, { apiVersion: '2023-10-16' });
738
+ const response = await stripe.issuing.cards.list({ limit: 100 });
739
+ return response.data.map(card => ({
740
+ id: card.id,
741
+ last4: card.last4,
742
+ cardholder_name: card.cardholder?.name || null,
743
+ description: card.metadata?.opencard_description ?? null,
744
+ status: card.status,
745
+ created: card.created,
746
+ }));
747
+ }
748
+ async function getRules() {
749
+ const store = new core_1.RulesStore(RULES_STORE_PATH);
750
+ return await store.listRules();
751
+ }
752
+ async function createRule() {
753
+ const rl = readline.createInterface({
754
+ input: process.stdin,
755
+ output: process.stdout,
756
+ });
757
+ const question = (prompt) => {
758
+ return new Promise(resolve => {
759
+ rl.question(prompt, resolve);
760
+ });
761
+ };
762
+ try {
763
+ console.log('\n📋 Create a New Spend Rule');
764
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
765
+ const limitStr = await question('Limit amount (in cents, e.g. 10000 for $100): ');
766
+ const periodStr = await question('Period (daily/monthly): ');
767
+ const onFailureStr = await question('On failure (decline/alert [not yet implemented]/pause [not yet implemented]): ');
768
+ const nameStr = await question('Rule name (optional): ');
769
+ const limit = parseInt(limitStr, 10);
770
+ const period = periodStr.toLowerCase().startsWith('m') ? 'monthly' : 'daily';
771
+ const onFailure = onFailureStr.toLowerCase() || 'decline';
772
+ if (isNaN(limit) || limit <= 0) {
773
+ console.error('Invalid limit amount');
774
+ rl.close();
775
+ return;
776
+ }
777
+ if (onFailure === 'alert' || onFailure === 'pause') {
778
+ console.warn(`\n⚠️ Note: the "${onFailure}" mode is not yet implemented — charges will be declined. Use "decline" for now.\n`);
779
+ }
780
+ const store = new core_1.RulesStore(RULES_STORE_PATH);
781
+ const ruleData = {
782
+ name: nameStr || undefined,
783
+ onFailure: onFailure,
784
+ dailyLimit: period === 'daily' ? limit : undefined,
785
+ monthlyLimit: period === 'monthly' ? limit : undefined,
786
+ };
787
+ const ruleId = await store.createRule(ruleData);
788
+ console.log('\n✅ Rule created successfully!');
789
+ console.log(` ID: ${ruleId}`);
790
+ console.log(` Name: ${nameStr || '—'}`);
791
+ console.log(` Limit: $${(limit / 100).toFixed(2)} (${period})`);
792
+ console.log(` On failure: ${onFailure}\n`);
793
+ rl.close();
794
+ }
795
+ catch (error) {
796
+ rl.close();
797
+ throw error;
798
+ }
799
+ }
800
+ async function runTest() {
801
+ const store = new core_1.RulesStore(RULES_STORE_PATH);
802
+ let rules = await store.listRules();
803
+ let usingTempRule = false;
804
+ let ruleId;
805
+ let rule;
806
+ // If no rules exist, create a temporary test rule
807
+ if (rules.length === 0) {
808
+ usingTempRule = true;
809
+ const tempRule = {
810
+ name: 'Test Rule (temporary)',
811
+ maxPerTransaction: 10000, // $100/txn
812
+ dailyLimit: 50000, // $500/day
813
+ onFailure: 'decline',
814
+ };
815
+ ruleId = await store.createRule(tempRule);
816
+ rule = tempRule;
817
+ rules = [{ id: ruleId, rule }];
818
+ }
819
+ else {
820
+ // Use the first configured rule
821
+ ({ id: ruleId, rule } = rules[0]);
822
+ }
823
+ const ruleName = rule.name || ruleId;
824
+ // ── Build test scenarios ────────────────────────────────────────────────
825
+ //
826
+ // Scenario 1: Should APPROVE — small amount, neutral category
827
+ // Scenario 2: Should DECLINE — amount exceeds per-transaction or daily limit
828
+ // Scenario 3: Should DECLINE or APPROVE — category test with Blue Bottle Coffee
829
+ //
830
+ // We derive realistic thresholds from the actual rule so the scenarios
831
+ // produce the intended outcomes regardless of what the user configured.
832
+ const perTxLimit = rule.maxPerTransaction;
833
+ const dailyLimit = rule.dailyLimit;
834
+ const monthlyLimit = rule.monthlyLimit;
835
+ const hasAllowedCategories = (rule.allowedCategories?.length ?? 0) > 0;
836
+ const hasBlockedCategories = (rule.blockedCategories?.length ?? 0) > 0;
837
+ // Scenario 1: small, approved amount in a neutral/allowed category
838
+ const approveCategory = hasAllowedCategories
839
+ ? rule.allowedCategories[0]
840
+ : 'software';
841
+ const approveAmount = perTxLimit
842
+ ? Math.min(Math.floor(perTxLimit * 0.25), 2500) // 25% of per-tx limit, max $25
843
+ : dailyLimit
844
+ ? Math.min(Math.floor(dailyLimit * 0.05), 2500) // 5% of daily, max $25
845
+ : 2500; // fallback: $25
846
+ // Scenario 2: amount that exceeds per-transaction limit (or daily/monthly if no per-tx)
847
+ let declineAmount;
848
+ let declineReason2;
849
+ if (perTxLimit !== undefined) {
850
+ declineAmount = perTxLimit + 5000; // $50 over limit
851
+ declineReason2 = 'exceeds per-transaction limit';
852
+ }
853
+ else if (dailyLimit !== undefined) {
854
+ declineAmount = dailyLimit + 5000;
855
+ declineReason2 = 'exceeds daily limit';
856
+ }
857
+ else if (monthlyLimit !== undefined) {
858
+ declineAmount = monthlyLimit + 5000;
859
+ declineReason2 = 'exceeds monthly limit';
860
+ }
861
+ else {
862
+ // No limits? Approve it anyway (edge case — show it passes)
863
+ declineAmount = 100000; // $1000
864
+ declineReason2 = 'exceeds limit';
865
+ }
866
+ // Scenario 3: Blue Bottle Coffee (restaurants) — check category rules
867
+ const thirdCategory = 'restaurants';
868
+ const thirdMerchant = 'Blue Bottle Coffee';
869
+ const thirdAmount = 500; // $5
870
+ // Will scenario 3 be approved or declined?
871
+ let scenario3Expected;
872
+ let scenario3DeclineReason;
873
+ if (hasAllowedCategories && !rule.allowedCategories.includes(thirdCategory)) {
874
+ // restaurants not in allowed list → declined
875
+ scenario3Expected = false;
876
+ scenario3DeclineReason = 'category not in allowed list';
877
+ }
878
+ else if (hasBlockedCategories && rule.blockedCategories.includes(thirdCategory)) {
879
+ // restaurants is blocked → declined
880
+ scenario3Expected = false;
881
+ scenario3DeclineReason = 'blocked category';
882
+ }
883
+ else {
884
+ // No category restriction → approved (amount is tiny)
885
+ scenario3Expected = true;
886
+ }
887
+ const scenarios = [
888
+ {
889
+ label: 'scenario1',
890
+ amount: approveAmount,
891
+ merchantName: 'Vercel Inc',
892
+ category: approveCategory,
893
+ expectedApproved: true,
894
+ },
895
+ {
896
+ label: 'scenario2',
897
+ amount: declineAmount,
898
+ merchantName: 'Best Buy',
899
+ category: 'electronics',
900
+ expectedApproved: false,
901
+ declineReason: declineReason2,
902
+ },
903
+ {
904
+ label: 'scenario3',
905
+ amount: thirdAmount,
906
+ merchantName: thirdMerchant,
907
+ category: thirdCategory,
908
+ expectedApproved: scenario3Expected,
909
+ declineReason: scenario3DeclineReason,
910
+ },
911
+ ];
912
+ // ── Run evaluations ─────────────────────────────────────────────────────
913
+ const results = scenarios.map(scenario => {
914
+ const auth = (0, core_1.mockAuthorization)({
915
+ amount: scenario.amount,
916
+ category: scenario.category,
917
+ merchantName: scenario.merchantName,
918
+ });
919
+ // Zero spend context — we're testing rule logic, not cumulative spend
920
+ const decision = (0, core_1.evaluateAuthorization)(auth, rule, {
921
+ dailySpend: 0,
922
+ monthlySpend: 0,
923
+ });
924
+ return {
925
+ ...scenario,
926
+ actualApproved: decision.approved,
927
+ reason: decision.reason,
928
+ passed: decision.approved === scenario.expectedApproved,
929
+ };
930
+ });
931
+ // ── Compute summary ─────────────────────────────────────────────────────
932
+ const approvedCount = results.filter(r => r.actualApproved).length;
933
+ const declinedCount = results.filter(r => !r.actualApproved).length;
934
+ // "working" means the engine made the expected decision for each scenario
935
+ const allCorrect = results.every(r => r.passed);
936
+ const coffeeMessage = '\n Your agent has a credit card now. Pretty wild.\n' +
937
+ ' Have it buy my agent a coffee → https://buymeacoffee.com/ovenbeardl ☕';
938
+ // ── Output ──────────────────────────────────────────────────────────────
939
+ if (outputJson) {
940
+ const jsonOutput = {
941
+ rule: { id: ruleId, name: ruleName },
942
+ usingTempRule,
943
+ results: results.map(r => ({
944
+ merchant: r.merchantName,
945
+ amount: r.amount,
946
+ amountDollars: `$${(r.amount / 100).toFixed(2)}`,
947
+ category: r.category,
948
+ approved: r.actualApproved,
949
+ expectedApproved: r.expectedApproved,
950
+ passed: r.passed,
951
+ reason: r.reason,
952
+ })),
953
+ summary: {
954
+ approved: approvedCount,
955
+ declined: declinedCount,
956
+ total: results.length,
957
+ allCorrect,
958
+ },
959
+ };
960
+ console.log(JSON.stringify(jsonOutput, null, 2));
961
+ // Coffee line always goes to stderr to avoid breaking JSON output
962
+ process.stderr.write(coffeeMessage + '\n');
963
+ return;
964
+ }
965
+ // Pretty-print output
966
+ console.log('\n🧪 Testing your rules...\n');
967
+ if (usingTempRule) {
968
+ console.log(' ⚠️ No rules configured — using temporary test rule ($100/txn, $500/day)\n');
969
+ }
970
+ console.log(` Rule: ${ruleName}\n`);
971
+ // Determine column widths for alignment
972
+ const lines = results.map(r => {
973
+ const amountStr = `$${(r.amount / 100).toFixed(2)}`;
974
+ const label = `${amountStr} at "${r.merchantName}" (${r.category})`;
975
+ return label;
976
+ });
977
+ const maxLabelLen = Math.max(...lines.map(l => l.length));
978
+ for (let i = 0; i < results.length; i++) {
979
+ const r = results[i];
980
+ const label = lines[i];
981
+ const padding = '.'.repeat(Math.max(1, maxLabelLen - label.length + 4));
982
+ const statusIcon = r.actualApproved ? '✓ APPROVED' : '✗ DECLINED';
983
+ let line = ` ${label} ${padding} ${statusIcon}`;
984
+ // Append decline reason for declined transactions
985
+ if (!r.actualApproved && r.declineReason) {
986
+ line += ` — ${r.declineReason}`;
987
+ }
988
+ else if (!r.actualApproved && r.reason) {
989
+ // Extract a short reason from the evaluation output
990
+ const shortReason = r.reason
991
+ .replace(/^(Transaction|Daily limit exceeded|Monthly limit exceeded|Merchant category)/, match => match.toLowerCase())
992
+ .replace(/\s+/g, ' ')
993
+ .trim();
994
+ // Use the first clause only
995
+ line += ` — ${shortReason.split('\n')[0]}`;
996
+ }
997
+ console.log(line);
998
+ }
999
+ // Summary line
1000
+ const summaryParts = [];
1001
+ if (approvedCount > 0)
1002
+ summaryParts.push(`${approvedCount} approved`);
1003
+ if (declinedCount > 0)
1004
+ summaryParts.push(`${declinedCount} declined`);
1005
+ const summary = summaryParts.join(', ');
1006
+ const statusMsg = allCorrect
1007
+ ? 'your rules are working.'
1008
+ : 'some results were unexpected — check your rules.';
1009
+ console.log(`\n ${summary} — ${statusMsg}`);
1010
+ console.log(coffeeMessage);
1011
+ console.log();
1012
+ }
1013
+ async function checkWebhookHealth(port) {
1014
+ try {
1015
+ const controller = new AbortController();
1016
+ const timeout = setTimeout(() => controller.abort(), 2000);
1017
+ const response = await fetch(`http://localhost:${port}/health`, {
1018
+ signal: controller.signal,
1019
+ });
1020
+ clearTimeout(timeout);
1021
+ return response.ok;
1022
+ }
1023
+ catch {
1024
+ return false;
1025
+ }
1026
+ }
1027
+ // ─── Serve Command Implementation ─────────────────────────────────────────────
1028
+ async function runServe(port, useTunnel, tunnelUrl) {
1029
+ // ── Validate STRIPE_SECRET_KEY ────────────────────────────────────────────
1030
+ const stripeKey = process.env.STRIPE_SECRET_KEY;
1031
+ if (!stripeKey) {
1032
+ console.error('[Serve] FATAL: STRIPE_SECRET_KEY environment variable is not set.');
1033
+ console.error('[Serve] Set it with: export STRIPE_SECRET_KEY=sk_test_...');
1034
+ process.exit(1);
1035
+ }
1036
+ const stripe = new stripe_1.default(stripeKey, { apiVersion: '2023-10-16' });
1037
+ console.log('[Serve] ✓ STRIPE_SECRET_KEY configured');
1038
+ // ── Start webhook server ──────────────────────────────────────────────────
1039
+ console.log(`[Serve] Starting webhook server on port ${port}...`);
1040
+ let serverStarted = false;
1041
+ const serverPromise = (0, webhook_server_1.startWebhookServer)(port);
1042
+ // Give server a moment to start
1043
+ const serverTimeout = new Promise((resolve) => {
1044
+ setTimeout(resolve, 1000);
1045
+ });
1046
+ await Promise.race([serverPromise, serverTimeout]);
1047
+ serverStarted = true;
1048
+ console.log('[Serve] ✓ Webhook server started');
1049
+ // ── Determine public URL ──────────────────────────────────────────────────
1050
+ let publicUrl;
1051
+ if (!useTunnel && tunnelUrl) {
1052
+ // Use provided URL directly
1053
+ publicUrl = tunnelUrl;
1054
+ console.log(`[Serve] Using provided URL: ${publicUrl}`);
1055
+ }
1056
+ else if (useTunnel) {
1057
+ // Create localtunnel
1058
+ console.log('[Serve] Creating public tunnel...');
1059
+ try {
1060
+ const tunnel = await (0, localtunnel_1.default)({ port, subdomain: 'opencard' });
1061
+ publicUrl = tunnel.url;
1062
+ console.log(`[Serve] ✓ Tunnel created: ${publicUrl}`);
1063
+ // Clean up tunnel on exit
1064
+ process.on('SIGINT', async () => {
1065
+ console.log('[Serve] Closing tunnel...');
1066
+ await tunnel.close();
1067
+ process.exit(0);
1068
+ });
1069
+ }
1070
+ catch (err) {
1071
+ console.error('[Serve] Failed to create tunnel:', err instanceof Error ? err.message : err);
1072
+ console.error('[Serve] Tip: localtunnel may be unavailable. Try running with --no-tunnel --url https://your-public-url');
1073
+ process.exit(1);
1074
+ }
1075
+ }
1076
+ else {
1077
+ console.error('[Serve] No public URL provided. Use --tunnel (default) or --no-tunnel --url <url>');
1078
+ process.exit(1);
1079
+ }
1080
+ // ── Register webhook with Stripe ──────────────────────────────────────────
1081
+ const webhookUrl = `${publicUrl}/webhooks/stripe`;
1082
+ console.log(`[Serve] Registering webhook endpoint with Stripe: ${webhookUrl}`);
1083
+ let endpointId = null;
1084
+ try {
1085
+ const endpoint = await stripe.webhookEndpoints.create({
1086
+ url: webhookUrl,
1087
+ enabled_events: ['issuing_authorization.request'],
1088
+ });
1089
+ endpointId = endpoint.id;
1090
+ console.log(`[Serve] ✓ Webhook endpoint registered: ${endpointId}`);
1091
+ console.log(`[Serve] ✓ Webhook secret: ${endpoint.secret}`);
1092
+ console.log(`[Serve] ⚠️ Save this secret and set: export STRIPE_WEBHOOK_SECRET=${endpoint.secret}`);
1093
+ }
1094
+ catch (err) {
1095
+ console.error('[Serve] Failed to register webhook:', err instanceof Error ? err.message : err);
1096
+ process.exit(1);
1097
+ }
1098
+ // ── Print status and listen ───────────────────────────────────────────────
1099
+ console.log('\n┌─────────────────────────────────────────────────────┐');
1100
+ console.log('│ OpenCard Webhook Server Ready │');
1101
+ console.log('├─────────────────────────────────────────────────────┤');
1102
+ console.log(`│ Public URL: ${webhookUrl.padEnd(43)}│`);
1103
+ console.log(`│ Endpoint ID: ${endpointId?.padEnd(40)}│`);
1104
+ console.log('│ │');
1105
+ console.log('│ Health check: GET http://localhost:' + port.toString().padEnd(10) + '/health │');
1106
+ console.log('│ Webhook: POST http://localhost:' + port.toString().padEnd(10) + '/webhooks/stripe │');
1107
+ console.log('│ │');
1108
+ console.log('│ Press Ctrl+C to stop and deregister webhook. │');
1109
+ console.log('└─────────────────────────────────────────────────────┘\n');
1110
+ // ── Handle SIGINT for cleanup ─────────────────────────────────────────────
1111
+ process.on('SIGINT', async () => {
1112
+ console.log('\n[Serve] Shutting down...');
1113
+ if (endpointId) {
1114
+ try {
1115
+ console.log('[Serve] Deregistering webhook endpoint...');
1116
+ await stripe.webhookEndpoints.del(endpointId);
1117
+ console.log('[Serve] ✓ Webhook endpoint deregistered');
1118
+ }
1119
+ catch (err) {
1120
+ console.error('[Serve] Failed to deregister webhook:', err instanceof Error ? err.message : err);
1121
+ }
1122
+ }
1123
+ console.log('[Serve] Bye!');
1124
+ process.exit(0);
1125
+ });
1126
+ // Keep the process running
1127
+ await new Promise(() => {
1128
+ // Never resolve — process is kept alive by event listeners
1129
+ });
1130
+ }
1131
+ function getBorderChars() {
1132
+ return {
1133
+ topBody: '─',
1134
+ topJoin: '┬',
1135
+ topLeft: '┌',
1136
+ topRight: '┐',
1137
+ bottomBody: '─',
1138
+ bottomJoin: '┴',
1139
+ bottomLeft: '└',
1140
+ bottomRight: '┘',
1141
+ bodyLeft: '│',
1142
+ bodyRight: '│',
1143
+ bodyJoin: '│',
1144
+ joinBody: '─',
1145
+ joinLeft: '├',
1146
+ joinRight: '┤',
1147
+ joinJoin: '┼',
1148
+ };
1149
+ }
1150
+ // ─── Approvals commands ───────────────────────────────────────────────────────
1151
+ /**
1152
+ * opencard approvals list
1153
+ * Show pending approval requests from the SQLite database
1154
+ */
1155
+ const approvalsCommand = commander_1.program
1156
+ .command('approvals')
1157
+ .description('Manage human-in-the-loop spending approval requests')
1158
+ .addHelpText('after', `
1159
+
1160
+ Workflow:
1161
+
1162
+ # View pending approvals
1163
+ opencard approvals list
1164
+
1165
+ # Approve a request
1166
+ opencard approvals approve <request-id>
1167
+ opencard approvals approve <request-id> --by "alice" --note "approved for travel"
1168
+
1169
+ # Deny a request
1170
+ opencard approvals deny <request-id> --note "exceeded budget"
1171
+ `);
1172
+ approvalsCommand
1173
+ .command('list')
1174
+ .description('List pending approval requests')
1175
+ .action(async (options, cmd) => {
1176
+ outputJson = cmd.parent?.parent?.opts()?.json || false;
1177
+ try {
1178
+ const db = new core_1.OpenCardDatabase();
1179
+ const pending = db.listPendingApprovalRequests();
1180
+ db.close();
1181
+ if (outputJson) {
1182
+ console.log(JSON.stringify({ pending }, null, 2));
1183
+ return;
1184
+ }
1185
+ if (pending.length === 0) {
1186
+ console.log('\n No pending approval requests.\n');
1187
+ return;
1188
+ }
1189
+ console.log('\nPending Approval Requests:\n');
1190
+ for (const req of pending) {
1191
+ const amountDollars = `$${(req.amount / 100).toFixed(2)}`;
1192
+ const createdAt = new Date(req.created_at.replace(' ', 'T') + 'Z');
1193
+ const expiresAt = new Date(req.expires_at.replace(' ', 'T') + 'Z');
1194
+ const now = new Date();
1195
+ const requestedAgo = formatTimeAgo(createdAt.toISOString());
1196
+ const expiresInMs = expiresAt.getTime() - now.getTime();
1197
+ const expiresInSec = Math.max(0, Math.floor(expiresInMs / 1000));
1198
+ const expiresInMin = Math.floor(expiresInSec / 60);
1199
+ const expiresInSecRem = expiresInSec % 60;
1200
+ const expiresStr = expiresInMs <= 0
1201
+ ? 'expired'
1202
+ : `${expiresInMin}m ${expiresInSecRem}s`;
1203
+ console.log(` ${req.id} ${amountDollars} at "${req.merchant_name}"`);
1204
+ console.log(` Reason: ${req.reason}`);
1205
+ console.log(` Card: ${req.card_id}${req.merchant_category ? ` · Category: ${req.merchant_category}` : ''}`);
1206
+ console.log(` Requested: ${requestedAgo} · Expires in ${expiresStr}`);
1207
+ console.log();
1208
+ console.log(` opencard approvals approve ${req.id}`);
1209
+ console.log(` opencard approvals deny ${req.id} --note "reason"`);
1210
+ console.log();
1211
+ }
1212
+ }
1213
+ catch (error) {
1214
+ console.error('Error listing approvals:', error instanceof Error ? error.message : error);
1215
+ process.exit(1);
1216
+ }
1217
+ });
1218
+ approvalsCommand
1219
+ .command('approve <id>')
1220
+ .description('Approve a pending approval request')
1221
+ .option('--by <who>', 'Your name/identifier (default: "operator")')
1222
+ .option('--note <note>', 'Optional note')
1223
+ .action(async (id, options) => {
1224
+ try {
1225
+ const db = new core_1.OpenCardDatabase();
1226
+ const request = db.approveRequest(id, options.by || 'operator', options.note);
1227
+ db.close();
1228
+ const amountDollars = `$${(request.amount / 100).toFixed(2)}`;
1229
+ console.log(`\n✅ Approved: ${id}`);
1230
+ console.log(` ${amountDollars} at "${request.merchant_name}"`);
1231
+ console.log(` Approved by: ${request.decided_by}`);
1232
+ if (request.decision_note) {
1233
+ console.log(` Note: ${request.decision_note}`);
1234
+ }
1235
+ console.log();
1236
+ }
1237
+ catch (error) {
1238
+ console.error('Error approving request:', error instanceof Error ? error.message : error);
1239
+ process.exit(1);
1240
+ }
1241
+ });
1242
+ approvalsCommand
1243
+ .command('deny <id>')
1244
+ .description('Deny a pending approval request')
1245
+ .option('--by <who>', 'Your name/identifier (default: "operator")')
1246
+ .option('--note <note>', 'Optional note explaining the denial')
1247
+ .action(async (id, options) => {
1248
+ try {
1249
+ const db = new core_1.OpenCardDatabase();
1250
+ const request = db.denyRequest(id, options.by || 'operator', options.note);
1251
+ db.close();
1252
+ const amountDollars = `$${(request.amount / 100).toFixed(2)}`;
1253
+ console.log(`\n✗ Denied: ${id}`);
1254
+ console.log(` ${amountDollars} at "${request.merchant_name}"`);
1255
+ console.log(` Denied by: ${request.decided_by}`);
1256
+ if (request.decision_note) {
1257
+ console.log(` Note: ${request.decision_note}`);
1258
+ }
1259
+ console.log();
1260
+ }
1261
+ catch (error) {
1262
+ console.error('Error denying request:', error instanceof Error ? error.message : error);
1263
+ process.exit(1);
1264
+ }
1265
+ });
1266
+ // ─── Parse and run ────────────────────────────────────────────────────────
1267
+ commander_1.program.parse(process.argv);
1268
+ // Show help if no command
1269
+ if (!process.argv.slice(2).length) {
1270
+ commander_1.program.outputHelp();
1271
+ }
1272
+ //# sourceMappingURL=index.js.map