@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.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1272 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
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
|