@opencard-dev/mcp-server 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 +64 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +186 -0
- package/dist/index.js.map +1 -0
- package/dist/tools.d.ts +353 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +1199 -0
- package/dist/tools.js.map +1 -0
- package/package.json +45 -0
- package/references/gotchas.md +141 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,1199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* OpenCard MCP Tool Definitions and Handlers
|
|
4
|
+
* ==================================== * Defines the MCP tool schemas and wires each tool to the real
|
|
5
|
+
* @opencard/core SDK so AI agents can manage virtual cards via MCP.
|
|
6
|
+
*
|
|
7
|
+
* ─── Architecture ────────────────────────────────────────────────────────────
|
|
8
|
+
*
|
|
9
|
+
* Each tool follows this pattern:
|
|
10
|
+
* 1. Extract + validate input args
|
|
11
|
+
* 2. Initialize StripeClient with the API key from env
|
|
12
|
+
* 3. Call the appropriate SDK method
|
|
13
|
+
* 4. Return a structured result or error object (never throw)
|
|
14
|
+
*
|
|
15
|
+
* ─── Error handling ──────────────────────────────────────────────────────────
|
|
16
|
+
*
|
|
17
|
+
* All handlers catch errors and return `{ status: 'error', message }` rather
|
|
18
|
+
* than throwing. This prevents the MCP server from crashing on Stripe API
|
|
19
|
+
* errors, network failures, or bad input. The caller decides what to do with
|
|
20
|
+
* the error.
|
|
21
|
+
*
|
|
22
|
+
* ─── Stripe key ──────────────────────────────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* The StripeClient reads STRIPE_SECRET_KEY from the environment by default.
|
|
25
|
+
* Callers can also pass it explicitly via the `stripe_secret_key` arg (useful
|
|
26
|
+
* when the MCP server is shared across multiple callers with different keys).
|
|
27
|
+
*/
|
|
28
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
29
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
30
|
+
};
|
|
31
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.openCardTools = void 0;
|
|
33
|
+
exports.createToolHandler = createToolHandler;
|
|
34
|
+
const core_1 = require("@opencard-dev/core");
|
|
35
|
+
const stripe_1 = __importDefault(require("stripe"));
|
|
36
|
+
// ─── Tool schemas ─────────────────────────────────────────────────────────────
|
|
37
|
+
/**
|
|
38
|
+
* MCP Tool definition for OpenCard.
|
|
39
|
+
* These schemas are consumed by MCP-compatible agents to discover available tools.
|
|
40
|
+
*/
|
|
41
|
+
exports.openCardTools = [
|
|
42
|
+
{
|
|
43
|
+
name: 'opencard_create_card',
|
|
44
|
+
description: 'Use when setting up a new agent that needs to make purchases. Creates a virtual Stripe Issuing card with spending rules attached. Requires an existing cardholder ID — if you don\'t have one, tell the human operator to create one via the Stripe dashboard or CLI first. This hits the Stripe API.',
|
|
45
|
+
inputSchema: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
agent_name: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
description: 'Name of the agent this card is for (e.g., research-bot, shopping-agent)',
|
|
51
|
+
},
|
|
52
|
+
cardholder_id: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
description: 'Stripe cardholder ID (must exist)',
|
|
55
|
+
},
|
|
56
|
+
rule_template: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
enum: ['balanced', 'category_locked', 'daily_limit', 'approval_gated'],
|
|
59
|
+
description: 'Pre-built rule template to apply',
|
|
60
|
+
},
|
|
61
|
+
daily_limit_cents: {
|
|
62
|
+
type: 'number',
|
|
63
|
+
description: 'Daily spending limit in CENTS (e.g., 2500 = $25.00, 100000 = $1,000.00). NOT dollars.',
|
|
64
|
+
},
|
|
65
|
+
max_per_transaction_cents: {
|
|
66
|
+
type: 'number',
|
|
67
|
+
description: 'Per-transaction limit in CENTS (e.g., 2500 = $25.00). NOT dollars.',
|
|
68
|
+
},
|
|
69
|
+
description: {
|
|
70
|
+
type: 'string',
|
|
71
|
+
description: "What this card is for (e.g., 'Engineering SaaS subscriptions — Vercel, AWS, Anthropic'). Helps agents pick the right card for each purchase.",
|
|
72
|
+
},
|
|
73
|
+
metadata: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
description: 'Custom metadata (tags, project ID, etc)',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
required: ['agent_name', 'cardholder_id'],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'opencard_get_balance',
|
|
83
|
+
description: 'Get spending totals and remaining budget for a card. Shows today\'s spend, monthly spend, and remaining amounts based on the card\'s rules. Call this to check if there\'s enough budget before making a purchase.',
|
|
84
|
+
inputSchema: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
card_id: {
|
|
88
|
+
type: 'string',
|
|
89
|
+
description: 'Stripe card ID',
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
required: ['card_id'],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'opencard_get_transactions',
|
|
97
|
+
description: 'Use for expense reporting, debugging declined charges, or reviewing what was purchased. Returns transaction history for a specific card. Supports filtering by status. For real-time spend totals, use get_balance instead.',
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: {
|
|
101
|
+
card_id: {
|
|
102
|
+
type: 'string',
|
|
103
|
+
description: 'Stripe card ID',
|
|
104
|
+
},
|
|
105
|
+
limit: {
|
|
106
|
+
type: 'number',
|
|
107
|
+
description: 'Max transactions to return (default 50)',
|
|
108
|
+
},
|
|
109
|
+
status: {
|
|
110
|
+
type: 'string',
|
|
111
|
+
enum: ['approved', 'declined', 'pending', 'captured', 'reversed'],
|
|
112
|
+
description: 'Filter by transaction status',
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
required: ['card_id'],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'opencard_pause_card',
|
|
120
|
+
description: 'Temporarily freeze a card when something is wrong — overspending, suspicious activity, or you need to investigate. All new charges are declined immediately. The card is NOT canceled — use resume_card to re-enable it later.',
|
|
121
|
+
inputSchema: {
|
|
122
|
+
type: 'object',
|
|
123
|
+
properties: {
|
|
124
|
+
card_id: {
|
|
125
|
+
type: 'string',
|
|
126
|
+
description: 'Stripe card ID',
|
|
127
|
+
},
|
|
128
|
+
reason: {
|
|
129
|
+
type: 'string',
|
|
130
|
+
description: 'Optional reason for pausing',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
required: ['card_id'],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'opencard_resume_card',
|
|
138
|
+
description: 'Re-enable a card that was paused. Only works on paused (inactive) cards, not canceled ones. Call this after resolving whatever caused the pause.',
|
|
139
|
+
inputSchema: {
|
|
140
|
+
type: 'object',
|
|
141
|
+
properties: {
|
|
142
|
+
card_id: {
|
|
143
|
+
type: 'string',
|
|
144
|
+
description: 'Stripe card ID',
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
required: ['card_id'],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'opencard_set_limits',
|
|
152
|
+
description: 'Update spending limits on a card when budget needs change. Sets hard limits enforced by Stripe directly (these work even if the OpenCard webhook server is down). Currently only "decline" mode is active (alert/pause modes are planned). Amounts are in CENTS.',
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
card_id: {
|
|
157
|
+
type: 'string',
|
|
158
|
+
description: 'Stripe card ID',
|
|
159
|
+
},
|
|
160
|
+
daily_limit_cents: {
|
|
161
|
+
type: 'number',
|
|
162
|
+
description: 'Daily limit in CENTS (e.g., 2500 = $25.00, 100000 = $1,000.00). NOT dollars.',
|
|
163
|
+
},
|
|
164
|
+
monthly_limit_cents: {
|
|
165
|
+
type: 'number',
|
|
166
|
+
description: 'Monthly limit in CENTS (e.g., 2500 = $25.00, 100000 = $1,000.00). NOT dollars.',
|
|
167
|
+
},
|
|
168
|
+
per_tx_limit_cents: {
|
|
169
|
+
type: 'number',
|
|
170
|
+
description: 'Per-transaction limit in CENTS (e.g., 2500 = $25.00). NOT dollars.',
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
required: ['card_id'],
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'opencard_get_card_details',
|
|
178
|
+
description: 'Get full details for a specific card when you need more than what list_cards provides — metadata, spending controls, Stripe-level config. For just checking budget, use get_balance instead.',
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: {
|
|
182
|
+
card_id: {
|
|
183
|
+
type: 'string',
|
|
184
|
+
description: 'Stripe card ID',
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
required: ['card_id'],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: 'opencard_list_cards',
|
|
192
|
+
description: 'Call this FIRST when you need to make a purchase or check spending — find the right card before doing anything else. Returns all cards with their rules, current spend, and remaining budget. Don\'t hardcode card IDs; always discover them here.',
|
|
193
|
+
inputSchema: {
|
|
194
|
+
type: 'object',
|
|
195
|
+
properties: {
|
|
196
|
+
status: {
|
|
197
|
+
type: 'string',
|
|
198
|
+
enum: ['active', 'inactive', 'canceled'],
|
|
199
|
+
description: 'Filter by card status (default: active)',
|
|
200
|
+
},
|
|
201
|
+
limit: {
|
|
202
|
+
type: 'number',
|
|
203
|
+
description: 'Max cards to return (default 20, max 100)',
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
required: [],
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: 'opencard_check_rules',
|
|
211
|
+
description: 'Call this before attempting any purchase to verify it will be approved. Dry-runs the charge against the card\'s rules locally — no real charge, no Stripe API call, instant response. Returns detailed breakdown of which checks passed or failed. Amounts are in CENTS (2500 = $25.00). NOTE: rule_id is required when multiple rules exist in the store; optional only if a single rule is configured.',
|
|
212
|
+
inputSchema: {
|
|
213
|
+
type: 'object',
|
|
214
|
+
properties: {
|
|
215
|
+
card_id: {
|
|
216
|
+
type: 'string',
|
|
217
|
+
description: 'Stripe card ID to check against',
|
|
218
|
+
},
|
|
219
|
+
amount_cents: {
|
|
220
|
+
type: 'number',
|
|
221
|
+
description: 'Amount in CENTS (e.g., 2500 = $25.00, 100000 = $1,000.00). NOT dollars.',
|
|
222
|
+
},
|
|
223
|
+
merchant_category: {
|
|
224
|
+
type: 'string',
|
|
225
|
+
description: 'MCC category code (e.g., "software", "restaurants")',
|
|
226
|
+
},
|
|
227
|
+
merchant_name: {
|
|
228
|
+
type: 'string',
|
|
229
|
+
description: 'Merchant name (for logging/context only)',
|
|
230
|
+
},
|
|
231
|
+
rule_id: {
|
|
232
|
+
type: 'string',
|
|
233
|
+
description: 'OpenCard rule ID. Required when multiple rules exist in the store. Optional only if a single rule is configured.',
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
required: ['card_id', 'amount_cents'],
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: 'opencard_request_approval',
|
|
241
|
+
description: 'Request human approval when you need to make a purchase that exceeds your normal limits or is unusual. Blocks until the human approves, denies, or the request times out (default 5 minutes). You MUST provide a reason — the human sees it. Approval gives you PERMISSION only — it does not execute the purchase. Amounts are in CENTS.',
|
|
242
|
+
inputSchema: {
|
|
243
|
+
type: 'object',
|
|
244
|
+
properties: {
|
|
245
|
+
card_id: {
|
|
246
|
+
type: 'string',
|
|
247
|
+
description: 'Card to charge',
|
|
248
|
+
},
|
|
249
|
+
amount_cents: {
|
|
250
|
+
type: 'number',
|
|
251
|
+
description: 'Requested amount in CENTS (e.g., 2500 = $25.00, 100000 = $1,000.00). NOT dollars.',
|
|
252
|
+
},
|
|
253
|
+
merchant_name: {
|
|
254
|
+
type: 'string',
|
|
255
|
+
description: 'Where the money is going',
|
|
256
|
+
},
|
|
257
|
+
merchant_category: {
|
|
258
|
+
type: 'string',
|
|
259
|
+
description: 'MCC category (optional)',
|
|
260
|
+
},
|
|
261
|
+
reason: {
|
|
262
|
+
type: 'string',
|
|
263
|
+
description: 'Why the agent needs this purchase (shown to human)',
|
|
264
|
+
},
|
|
265
|
+
timeout_seconds: {
|
|
266
|
+
type: 'number',
|
|
267
|
+
description: 'How long to wait for approval in seconds (default: 300)',
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
required: ['card_id', 'amount_cents', 'merchant_name', 'reason'],
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
];
|
|
274
|
+
// ─── Tool handlers ────────────────────────────────────────────────────────────
|
|
275
|
+
/**
|
|
276
|
+
* Routes an MCP tool call to the appropriate handler and returns the result.
|
|
277
|
+
*
|
|
278
|
+
* All handlers are async and catch their own errors — this function will
|
|
279
|
+
* always return a result object, never throw. Unknown tool names return an
|
|
280
|
+
* error result rather than crashing.
|
|
281
|
+
*
|
|
282
|
+
* @param toolName - The MCP tool name (e.g. 'opencard_create_card')
|
|
283
|
+
* @param args - The tool's input arguments from the MCP request
|
|
284
|
+
* @returns A result object. Structure varies per tool; always includes `status`.
|
|
285
|
+
*/
|
|
286
|
+
async function createToolHandler(toolName, args) {
|
|
287
|
+
switch (toolName) {
|
|
288
|
+
case 'opencard_create_card':
|
|
289
|
+
return handleCreateCard(args);
|
|
290
|
+
case 'opencard_get_balance':
|
|
291
|
+
return handleGetBalance(args);
|
|
292
|
+
case 'opencard_get_transactions':
|
|
293
|
+
return handleGetTransactions(args);
|
|
294
|
+
case 'opencard_pause_card':
|
|
295
|
+
return handlePauseCard(args);
|
|
296
|
+
case 'opencard_resume_card':
|
|
297
|
+
return handleResumeCard(args);
|
|
298
|
+
case 'opencard_set_limits':
|
|
299
|
+
return handleSetLimits(args);
|
|
300
|
+
case 'opencard_get_card_details':
|
|
301
|
+
return handleGetCardDetails(args);
|
|
302
|
+
case 'opencard_check_rules':
|
|
303
|
+
return handleCheckRules(args);
|
|
304
|
+
case 'opencard_list_cards':
|
|
305
|
+
return handleListCards(args);
|
|
306
|
+
case 'opencard_request_approval':
|
|
307
|
+
return handleRequestApproval(args);
|
|
308
|
+
default:
|
|
309
|
+
return {
|
|
310
|
+
status: 'error',
|
|
311
|
+
message: `Unknown tool: ${toolName}`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// ─── Individual handlers ──────────────────────────────────────────────────────
|
|
316
|
+
/**
|
|
317
|
+
* opencard_create_card
|
|
318
|
+
* Creates a new virtual card under the specified cardholder.
|
|
319
|
+
* Optionally applies a spend-rule template and custom limit overrides.
|
|
320
|
+
*/
|
|
321
|
+
async function handleCreateCard(args) {
|
|
322
|
+
try {
|
|
323
|
+
const agentName = args.agent_name;
|
|
324
|
+
const cardholderId = args.cardholder_id;
|
|
325
|
+
if (!agentName || !cardholderId) {
|
|
326
|
+
return { status: 'error', message: 'agent_name and cardholder_id are required' };
|
|
327
|
+
}
|
|
328
|
+
// Build spend rules from optional template + overrides
|
|
329
|
+
let rulesBuilder = args.rule_template
|
|
330
|
+
? core_1.SpendRules.template(args.rule_template)
|
|
331
|
+
: new core_1.SpendRules();
|
|
332
|
+
if (typeof args.daily_limit_cents === 'number') {
|
|
333
|
+
rulesBuilder = rulesBuilder.dailyLimit(args.daily_limit_cents);
|
|
334
|
+
}
|
|
335
|
+
if (typeof args.max_per_transaction_cents === 'number') {
|
|
336
|
+
rulesBuilder = rulesBuilder.maxPerTx(args.max_per_transaction_cents);
|
|
337
|
+
}
|
|
338
|
+
const rules = rulesBuilder.build();
|
|
339
|
+
const client = new core_1.StripeClient();
|
|
340
|
+
const card = await client.createCard(cardholderId, {
|
|
341
|
+
agentName,
|
|
342
|
+
rules,
|
|
343
|
+
description: args.description || undefined,
|
|
344
|
+
metadata: args.metadata || {},
|
|
345
|
+
});
|
|
346
|
+
return {
|
|
347
|
+
status: 'success',
|
|
348
|
+
card: {
|
|
349
|
+
id: card.id,
|
|
350
|
+
last4: card.last4,
|
|
351
|
+
expiry: card.expiry,
|
|
352
|
+
status: card.status,
|
|
353
|
+
agentName: card.agentName,
|
|
354
|
+
description: card.description,
|
|
355
|
+
rules: card.rules,
|
|
356
|
+
createdAt: card.createdAt,
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
return { status: 'error', message: String(err) };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Formats a timestamp as a human-readable relative time string (e.g. "2 hours ago").
|
|
366
|
+
*/
|
|
367
|
+
function formatRelativeTime(dateStr) {
|
|
368
|
+
if (!dateStr)
|
|
369
|
+
return 'unknown';
|
|
370
|
+
const date = new Date(dateStr);
|
|
371
|
+
const now = new Date();
|
|
372
|
+
const diffMs = now.getTime() - date.getTime();
|
|
373
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
374
|
+
if (diffMins < 1)
|
|
375
|
+
return 'just now';
|
|
376
|
+
if (diffMins < 60)
|
|
377
|
+
return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
|
|
378
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
379
|
+
if (diffHours < 24)
|
|
380
|
+
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
|
381
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
382
|
+
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* opencard_get_balance
|
|
386
|
+
* Returns per-card spend totals from SQLite (authorizations + transactions),
|
|
387
|
+
* enriched with rule limits from RulesStore. Falls back to in-memory tracker
|
|
388
|
+
* if SQLite is unavailable.
|
|
389
|
+
*
|
|
390
|
+
* R1: Query SQLite for daily/monthly authorizations and all-time transactions
|
|
391
|
+
* R2: New response shape with spend, limits, remaining, recent_transactions
|
|
392
|
+
* R3: Fallback to in-memory tracker if SQLite unavailable
|
|
393
|
+
* R4: Look up rule limits from RulesStore via card metadata opencard_rule_id
|
|
394
|
+
* R5: No more account-level available/reserved/spent fields
|
|
395
|
+
* R6: Updated tool description (see schema above)
|
|
396
|
+
*/
|
|
397
|
+
async function handleGetBalance(args) {
|
|
398
|
+
try {
|
|
399
|
+
const cardId = args.card_id;
|
|
400
|
+
if (!cardId) {
|
|
401
|
+
return { status: 'error', message: 'card_id is required' };
|
|
402
|
+
}
|
|
403
|
+
// ── R4: Fetch card metadata to get rule ID and limits ────────────────
|
|
404
|
+
const secretKey = process.env.STRIPE_SECRET_KEY;
|
|
405
|
+
let ruleLimits = {
|
|
406
|
+
daily_limit_cents: null,
|
|
407
|
+
monthly_limit_cents: null,
|
|
408
|
+
max_per_transaction_cents: null,
|
|
409
|
+
};
|
|
410
|
+
if (secretKey) {
|
|
411
|
+
try {
|
|
412
|
+
const stripe = new stripe_1.default(secretKey, { apiVersion: '2023-10-16' });
|
|
413
|
+
const stripeCard = await stripe.issuing.cards.retrieve(cardId);
|
|
414
|
+
const metadata = (stripeCard.metadata ?? {});
|
|
415
|
+
const ruleId = metadata.opencard_rule_id ?? null;
|
|
416
|
+
if (ruleId) {
|
|
417
|
+
const rule = await core_1.rulesStore.getRule(ruleId);
|
|
418
|
+
if (rule) {
|
|
419
|
+
ruleLimits = {
|
|
420
|
+
daily_limit_cents: rule.dailyLimit ?? null,
|
|
421
|
+
monthly_limit_cents: rule.monthlyLimit ?? null,
|
|
422
|
+
max_per_transaction_cents: rule.maxPerTransaction ?? null,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// Card fetch or rule lookup failed — proceed with null limits
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// ── R1: Query SQLite for spend totals ────────────────────────────────
|
|
432
|
+
let db = null;
|
|
433
|
+
try {
|
|
434
|
+
db = (0, core_1.initDatabase)();
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
// SQLite unavailable — will fall back to in-memory tracker (R3)
|
|
438
|
+
}
|
|
439
|
+
if (db) {
|
|
440
|
+
// SQLite is available — query authorizations and transactions
|
|
441
|
+
try {
|
|
442
|
+
const rawDb = db.getDb();
|
|
443
|
+
// Spend from authorizations table with status='approved' for real-time budget enforcement.
|
|
444
|
+
// We use authorizations (not transactions) because:
|
|
445
|
+
// - authorizations are real-time and status reflects rule enforcement decisions
|
|
446
|
+
// - transactions are a subset of authorizations that were captured by Stripe
|
|
447
|
+
// - using the same table ensures budget enforcement and reporting show the same spend totals
|
|
448
|
+
// - this matches CLI status and MCP list_cards for consistency
|
|
449
|
+
// Daily spend: approved authorizations from midnight UTC today
|
|
450
|
+
const dailyResult = rawDb
|
|
451
|
+
.prepare("SELECT COALESCE(SUM(amount), 0) as total FROM authorizations WHERE card_id = ? AND status = 'approved' AND created_at >= date('now')")
|
|
452
|
+
.get(cardId);
|
|
453
|
+
// Monthly spend: approved authorizations from 1st of current month UTC
|
|
454
|
+
const monthlyResult = rawDb
|
|
455
|
+
.prepare("SELECT COALESCE(SUM(amount), 0) as total FROM authorizations WHERE card_id = ? AND status = 'approved' AND created_at >= strftime('%Y-%m-01', 'now')")
|
|
456
|
+
.get(cardId);
|
|
457
|
+
// All-time spend: captured transactions
|
|
458
|
+
const allTimeResult = rawDb
|
|
459
|
+
.prepare("SELECT COALESCE(SUM(amount), 0) as total FROM transactions WHERE card_id = ? AND status = 'captured'")
|
|
460
|
+
.get(cardId);
|
|
461
|
+
// Recent transactions (last 5 captured, newest first)
|
|
462
|
+
const recentRows = rawDb
|
|
463
|
+
.prepare("SELECT amount, merchant_name, merchant_category, stripe_created_at FROM transactions WHERE card_id = ? AND status = 'captured' ORDER BY stripe_created_at DESC LIMIT 5")
|
|
464
|
+
.all(cardId);
|
|
465
|
+
const todayCents = dailyResult.total;
|
|
466
|
+
const thisMonthCents = monthlyResult.total;
|
|
467
|
+
const allTimeCents = allTimeResult.total;
|
|
468
|
+
const recentTransactions = recentRows.map((row) => ({
|
|
469
|
+
amount_cents: row.amount,
|
|
470
|
+
merchant: row.merchant_name ?? 'Unknown',
|
|
471
|
+
category: row.merchant_category ?? 'unknown',
|
|
472
|
+
when: formatRelativeTime(row.stripe_created_at),
|
|
473
|
+
}));
|
|
474
|
+
const result = {
|
|
475
|
+
status: 'success',
|
|
476
|
+
card_id: cardId,
|
|
477
|
+
spend: {
|
|
478
|
+
today_cents: todayCents,
|
|
479
|
+
this_month_cents: thisMonthCents,
|
|
480
|
+
all_time_cents: allTimeCents,
|
|
481
|
+
},
|
|
482
|
+
limits: {
|
|
483
|
+
daily_limit_cents: ruleLimits.daily_limit_cents,
|
|
484
|
+
monthly_limit_cents: ruleLimits.monthly_limit_cents,
|
|
485
|
+
max_per_transaction_cents: ruleLimits.max_per_transaction_cents,
|
|
486
|
+
},
|
|
487
|
+
remaining: {
|
|
488
|
+
today_cents: ruleLimits.daily_limit_cents !== null
|
|
489
|
+
? Math.max(0, ruleLimits.daily_limit_cents - todayCents)
|
|
490
|
+
: null,
|
|
491
|
+
this_month_cents: ruleLimits.monthly_limit_cents !== null
|
|
492
|
+
? Math.max(0, ruleLimits.monthly_limit_cents - thisMonthCents)
|
|
493
|
+
: null,
|
|
494
|
+
},
|
|
495
|
+
recent_transactions: recentTransactions,
|
|
496
|
+
data_source: 'sqlite',
|
|
497
|
+
};
|
|
498
|
+
db.close();
|
|
499
|
+
return result;
|
|
500
|
+
}
|
|
501
|
+
catch (dbErr) {
|
|
502
|
+
// Query failed — fall through to in-memory fallback
|
|
503
|
+
try {
|
|
504
|
+
db.close();
|
|
505
|
+
}
|
|
506
|
+
catch { /* ignore */ }
|
|
507
|
+
db = null;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// ── R3: Fallback to in-memory tracker ────────────────────────────────
|
|
511
|
+
const trackerInstance = new core_1.TransactionTracker();
|
|
512
|
+
const todayCents = trackerInstance.getDailySpend(cardId);
|
|
513
|
+
const thisMonthCents = trackerInstance.getMonthlySpend(cardId);
|
|
514
|
+
return {
|
|
515
|
+
status: 'success',
|
|
516
|
+
card_id: cardId,
|
|
517
|
+
spend: {
|
|
518
|
+
today_cents: todayCents,
|
|
519
|
+
this_month_cents: thisMonthCents,
|
|
520
|
+
all_time_cents: null, // Not available in in-memory tracker
|
|
521
|
+
},
|
|
522
|
+
limits: {
|
|
523
|
+
daily_limit_cents: ruleLimits.daily_limit_cents,
|
|
524
|
+
monthly_limit_cents: ruleLimits.monthly_limit_cents,
|
|
525
|
+
max_per_transaction_cents: ruleLimits.max_per_transaction_cents,
|
|
526
|
+
},
|
|
527
|
+
remaining: {
|
|
528
|
+
today_cents: ruleLimits.daily_limit_cents !== null
|
|
529
|
+
? Math.max(0, ruleLimits.daily_limit_cents - todayCents)
|
|
530
|
+
: null,
|
|
531
|
+
this_month_cents: ruleLimits.monthly_limit_cents !== null
|
|
532
|
+
? Math.max(0, ruleLimits.monthly_limit_cents - thisMonthCents)
|
|
533
|
+
: null,
|
|
534
|
+
},
|
|
535
|
+
recent_transactions: [],
|
|
536
|
+
data_source: 'in_memory',
|
|
537
|
+
warning: 'SQLite database unavailable. Spend data is from in-memory tracker only and will reset on server restart.',
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
catch (err) {
|
|
541
|
+
return { status: 'error', message: String(err) };
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* opencard_get_transactions
|
|
546
|
+
* Retrieves the transaction history for a card from Stripe Issuing.
|
|
547
|
+
* Note: These are captured/settled transactions, not pending authorizations.
|
|
548
|
+
* Pending authorizations are handled via webhooks in real time.
|
|
549
|
+
*/
|
|
550
|
+
async function handleGetTransactions(args) {
|
|
551
|
+
try {
|
|
552
|
+
const cardId = args.card_id;
|
|
553
|
+
if (!cardId) {
|
|
554
|
+
return { status: 'error', message: 'card_id is required' };
|
|
555
|
+
}
|
|
556
|
+
const client = new core_1.StripeClient();
|
|
557
|
+
const transactions = await client.getTransactions(cardId, {
|
|
558
|
+
limit: typeof args.limit === 'number' ? args.limit : 50,
|
|
559
|
+
});
|
|
560
|
+
// Optionally filter by status if the caller asked for a specific status
|
|
561
|
+
const statusFilter = args.status;
|
|
562
|
+
const filtered = statusFilter
|
|
563
|
+
? transactions.filter((tx) => tx.status === statusFilter)
|
|
564
|
+
: transactions;
|
|
565
|
+
return {
|
|
566
|
+
status: 'success',
|
|
567
|
+
card_id: cardId,
|
|
568
|
+
count: filtered.length,
|
|
569
|
+
transactions: filtered.map((tx) => ({
|
|
570
|
+
id: tx.id,
|
|
571
|
+
amount: tx.amount,
|
|
572
|
+
currency: tx.currency,
|
|
573
|
+
merchant_name: tx.merchant.name,
|
|
574
|
+
merchant_category: tx.merchant.category,
|
|
575
|
+
status: tx.status,
|
|
576
|
+
created_at: tx.createdAt,
|
|
577
|
+
})),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
catch (err) {
|
|
581
|
+
return { status: 'error', message: String(err) };
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* opencard_pause_card
|
|
586
|
+
* Sets the card status to 'inactive', blocking all new charges.
|
|
587
|
+
* The card can be re-enabled via opencard_resume_card.
|
|
588
|
+
* Useful for temporarily suspending an agent that has exceeded its budget.
|
|
589
|
+
*/
|
|
590
|
+
async function handlePauseCard(args) {
|
|
591
|
+
try {
|
|
592
|
+
const cardId = args.card_id;
|
|
593
|
+
if (!cardId) {
|
|
594
|
+
return { status: 'error', message: 'card_id is required' };
|
|
595
|
+
}
|
|
596
|
+
const client = new core_1.StripeClient();
|
|
597
|
+
const card = await client.pauseCard(cardId);
|
|
598
|
+
return {
|
|
599
|
+
status: 'success',
|
|
600
|
+
card_id: card.id,
|
|
601
|
+
new_status: card.status,
|
|
602
|
+
reason: args.reason ?? null,
|
|
603
|
+
message: `Card ${card.id} paused. All new charges will be declined until resumed.`,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
catch (err) {
|
|
607
|
+
return { status: 'error', message: String(err) };
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* opencard_resume_card
|
|
612
|
+
* Sets the card status back to 'active', re-enabling new charges.
|
|
613
|
+
* Only works on cards that were paused (inactive), not canceled ones.
|
|
614
|
+
*/
|
|
615
|
+
async function handleResumeCard(args) {
|
|
616
|
+
try {
|
|
617
|
+
const cardId = args.card_id;
|
|
618
|
+
if (!cardId) {
|
|
619
|
+
return { status: 'error', message: 'card_id is required' };
|
|
620
|
+
}
|
|
621
|
+
const client = new core_1.StripeClient();
|
|
622
|
+
const card = await client.resumeCard(cardId);
|
|
623
|
+
return {
|
|
624
|
+
status: 'success',
|
|
625
|
+
card_id: card.id,
|
|
626
|
+
new_status: card.status,
|
|
627
|
+
message: `Card ${card.id} resumed. Charges are now accepted again.`,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
catch (err) {
|
|
631
|
+
return { status: 'error', message: String(err) };
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* opencard_set_limits
|
|
636
|
+
* Updates Stripe-native spending limits on the card.
|
|
637
|
+
* These limits are enforced by Stripe regardless of whether the OpenCard
|
|
638
|
+
* webhook server is running — they're the hard floor under our soft rules.
|
|
639
|
+
*
|
|
640
|
+
* Accepts any combination of daily, monthly, and per-transaction limits.
|
|
641
|
+
* At least one limit must be specified.
|
|
642
|
+
*/
|
|
643
|
+
async function handleSetLimits(args) {
|
|
644
|
+
try {
|
|
645
|
+
const cardId = args.card_id;
|
|
646
|
+
if (!cardId) {
|
|
647
|
+
return { status: 'error', message: 'card_id is required' };
|
|
648
|
+
}
|
|
649
|
+
// Build the spending limits array from whichever limits were provided.
|
|
650
|
+
// Stripe requires at least one limit; we return an error if none given.
|
|
651
|
+
const limits = [];
|
|
652
|
+
if (typeof args.daily_limit_cents === 'number') {
|
|
653
|
+
limits.push({ amount: args.daily_limit_cents, intervalType: 'daily', intervalCurrencyUnit: 'usd' });
|
|
654
|
+
}
|
|
655
|
+
if (typeof args.monthly_limit_cents === 'number') {
|
|
656
|
+
limits.push({ amount: args.monthly_limit_cents, intervalType: 'monthly', intervalCurrencyUnit: 'usd' });
|
|
657
|
+
}
|
|
658
|
+
if (typeof args.per_tx_limit_cents === 'number') {
|
|
659
|
+
limits.push({ amount: args.per_tx_limit_cents, intervalType: 'per_authorization', intervalCurrencyUnit: 'usd' });
|
|
660
|
+
}
|
|
661
|
+
if (limits.length === 0) {
|
|
662
|
+
return {
|
|
663
|
+
status: 'error',
|
|
664
|
+
message: 'At least one of daily_limit_cents, monthly_limit_cents, or per_tx_limit_cents must be specified',
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
const client = new core_1.StripeClient();
|
|
668
|
+
const card = await client.setSpendingLimits(cardId, limits);
|
|
669
|
+
return {
|
|
670
|
+
status: 'success',
|
|
671
|
+
card_id: card.id,
|
|
672
|
+
limits_applied: limits,
|
|
673
|
+
spending_controls: card.spendingControls,
|
|
674
|
+
message: `Spending limits updated on card ${card.id}.`,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
return { status: 'error', message: String(err) };
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* opencard_get_card_details
|
|
683
|
+
* Retrieves full card details from Stripe including status, expiry,
|
|
684
|
+
* agent name, applied rules, and spending controls.
|
|
685
|
+
*/
|
|
686
|
+
async function handleGetCardDetails(args) {
|
|
687
|
+
try {
|
|
688
|
+
const cardId = args.card_id;
|
|
689
|
+
if (!cardId) {
|
|
690
|
+
return { status: 'error', message: 'card_id is required' };
|
|
691
|
+
}
|
|
692
|
+
const client = new core_1.StripeClient();
|
|
693
|
+
const card = await client.getCard(cardId);
|
|
694
|
+
return {
|
|
695
|
+
status: 'success',
|
|
696
|
+
card: {
|
|
697
|
+
id: card.id,
|
|
698
|
+
last4: card.last4,
|
|
699
|
+
brand: card.brand,
|
|
700
|
+
expiry: card.expiry,
|
|
701
|
+
status: card.status,
|
|
702
|
+
cardholder_id: card.cardholderId,
|
|
703
|
+
agent_name: card.agentName,
|
|
704
|
+
description: card.description,
|
|
705
|
+
rules: card.rules,
|
|
706
|
+
spending_controls: card.spendingControls,
|
|
707
|
+
metadata: card.metadata,
|
|
708
|
+
created_at: card.createdAt,
|
|
709
|
+
},
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
catch (err) {
|
|
713
|
+
return { status: 'error', message: String(err) };
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
|
|
718
|
+
* opencard_list_cards
|
|
719
|
+
* Lists all cards from Stripe Issuing, enriched with rule details and
|
|
720
|
+
* best-effort spend data from SQLite. Designed for agent discovery — call
|
|
721
|
+
* this first to find the right card ID before any other operation.
|
|
722
|
+
*
|
|
723
|
+
* Spend data is pulled from the local SQLite DB (populated by the webhook
|
|
724
|
+
* server). If the DB is unavailable (hasn't been set up yet), spend is
|
|
725
|
+
* returned as null — the card list still works fine without it.
|
|
726
|
+
*
|
|
727
|
+
* Rule data is pulled from RulesStore using the opencard_rule_id stored in
|
|
728
|
+
* each card's Stripe metadata. If no rule is assigned, rule is null.
|
|
729
|
+
*/
|
|
730
|
+
async function handleListCards(args) {
|
|
731
|
+
try {
|
|
732
|
+
const statusFilter = args.status ?? 'active';
|
|
733
|
+
const limit = typeof args.limit === 'number' ? Math.min(args.limit, 100) : 20;
|
|
734
|
+
// ── Fetch cards from Stripe ────────────────────────────────────────────
|
|
735
|
+
const secretKey = process.env.STRIPE_SECRET_KEY;
|
|
736
|
+
if (!secretKey) {
|
|
737
|
+
return { status: 'error', message: 'STRIPE_SECRET_KEY must be set' };
|
|
738
|
+
}
|
|
739
|
+
const stripe = new stripe_1.default(secretKey, { apiVersion: '2023-10-16' });
|
|
740
|
+
const stripeCards = await stripe.issuing.cards.list({
|
|
741
|
+
status: statusFilter,
|
|
742
|
+
limit,
|
|
743
|
+
});
|
|
744
|
+
// ── Best-effort SQLite spend lookup ───────────────────────────────────
|
|
745
|
+
// Open DB once for all cards; if it throws, spend will be null for all.
|
|
746
|
+
let db = null;
|
|
747
|
+
try {
|
|
748
|
+
db = (0, core_1.initDatabase)();
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
// DB unavailable — spend will be null for all cards (per R3)
|
|
752
|
+
}
|
|
753
|
+
// Helper: compute today_cents and this_month_cents for a card from SQLite.
|
|
754
|
+
// Returns null if DB is not available.
|
|
755
|
+
// Uses authorizations table with status='approved' for consistency with get_balance.
|
|
756
|
+
// We use authorizations (not transactions) because:
|
|
757
|
+
// - authorizations are real-time and status reflects rule enforcement decisions
|
|
758
|
+
// - transactions are a subset of authorizations that were captured by Stripe
|
|
759
|
+
// - using the same table ensures budget enforcement and reporting show the same spend totals
|
|
760
|
+
function getSpendFromDb(cardId) {
|
|
761
|
+
if (!db)
|
|
762
|
+
return null;
|
|
763
|
+
try {
|
|
764
|
+
const rawDb = db.getDb();
|
|
765
|
+
// Daily spend: approved authorizations from midnight UTC today
|
|
766
|
+
const todayResult = rawDb
|
|
767
|
+
.prepare("SELECT COALESCE(SUM(amount), 0) as total FROM authorizations WHERE card_id = ? AND status = 'approved' AND created_at >= date('now')")
|
|
768
|
+
.get(cardId);
|
|
769
|
+
// Monthly spend: approved authorizations from 1st of current month UTC
|
|
770
|
+
const monthResult = rawDb
|
|
771
|
+
.prepare("SELECT COALESCE(SUM(amount), 0) as total FROM authorizations WHERE card_id = ? AND status = 'approved' AND created_at >= strftime('%Y-%m-01', 'now')")
|
|
772
|
+
.get(cardId);
|
|
773
|
+
return {
|
|
774
|
+
today_cents: todayResult.total,
|
|
775
|
+
this_month_cents: monthResult.total,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
// ── Build enriched card list ───────────────────────────────────────────
|
|
783
|
+
const cards = await Promise.all(stripeCards.data.map(async (stripeCard) => {
|
|
784
|
+
const metadata = (stripeCard.metadata ?? {});
|
|
785
|
+
const agentName = metadata.agentName ?? metadata.agent_name ?? null;
|
|
786
|
+
// Rule lookup: read opencard_rule_id from metadata, fetch from RulesStore
|
|
787
|
+
const ruleId = metadata.opencard_rule_id ?? null;
|
|
788
|
+
let rule = null;
|
|
789
|
+
if (ruleId) {
|
|
790
|
+
try {
|
|
791
|
+
const spendRule = await core_1.rulesStore.getRule(ruleId);
|
|
792
|
+
if (spendRule) {
|
|
793
|
+
rule = {
|
|
794
|
+
id: ruleId,
|
|
795
|
+
name: spendRule.name,
|
|
796
|
+
daily_limit_cents: spendRule.dailyLimit,
|
|
797
|
+
monthly_limit_cents: spendRule.monthlyLimit,
|
|
798
|
+
max_per_transaction_cents: spendRule.maxPerTransaction,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
catch {
|
|
803
|
+
// Rule store unavailable — return null for rule gracefully
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// Spend from SQLite (best-effort, null if DB not available)
|
|
807
|
+
const spend = getSpendFromDb(stripeCard.id);
|
|
808
|
+
const description = metadata.opencard_description ?? null;
|
|
809
|
+
return {
|
|
810
|
+
id: stripeCard.id,
|
|
811
|
+
last4: stripeCard.last4,
|
|
812
|
+
name: agentName,
|
|
813
|
+
description,
|
|
814
|
+
status: stripeCard.status,
|
|
815
|
+
rule,
|
|
816
|
+
spend,
|
|
817
|
+
created_at: new Date(stripeCard.created * 1000).toISOString(),
|
|
818
|
+
};
|
|
819
|
+
}));
|
|
820
|
+
// Close DB when done
|
|
821
|
+
if (db) {
|
|
822
|
+
try {
|
|
823
|
+
db.close();
|
|
824
|
+
}
|
|
825
|
+
catch { /* ignore close errors */ }
|
|
826
|
+
}
|
|
827
|
+
return {
|
|
828
|
+
status: 'success',
|
|
829
|
+
count: cards.length,
|
|
830
|
+
cards,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
catch (err) {
|
|
834
|
+
return { status: 'error', message: String(err) };
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* opencard_check_rules
|
|
839
|
+
* Dry-runs a purchase against the card's current rules without making
|
|
840
|
+
* any real charge or Stripe API calls. Uses:
|
|
841
|
+
* 1. RulesStore to look up the card's rule (via card metadata opencard_rule_id)
|
|
842
|
+
* 2. SQLite to get current daily/monthly spend (same queries as CLI status)
|
|
843
|
+
* 3. evaluateAuthorization from the rules engine for the decision
|
|
844
|
+
*
|
|
845
|
+
* Entirely local — zero Stripe API calls.
|
|
846
|
+
*/
|
|
847
|
+
async function handleCheckRules(args) {
|
|
848
|
+
try {
|
|
849
|
+
const cardId = args.card_id;
|
|
850
|
+
const amountCents = args.amount_cents;
|
|
851
|
+
if (!cardId) {
|
|
852
|
+
return { status: 'error', message: 'card_id is required' };
|
|
853
|
+
}
|
|
854
|
+
if (typeof amountCents !== 'number' || amountCents < 0) {
|
|
855
|
+
return { status: 'error', message: 'amount_cents must be a non-negative number' };
|
|
856
|
+
}
|
|
857
|
+
const merchantCategory = args.merchant_category ?? '';
|
|
858
|
+
const merchantName = args.merchant_name ?? 'unknown merchant';
|
|
859
|
+
// ── Step 1: Look up the rule for this card ────────────────────────────────
|
|
860
|
+
// We need the card's opencard_rule_id to find the rule.
|
|
861
|
+
// Per the brief, we use getRulesForCard logic — but we don't have a
|
|
862
|
+
// Stripe.Issuing.Card object here without an API call. Instead, we accept
|
|
863
|
+
// either a rule_id passed directly, or we check the rules store.
|
|
864
|
+
// Per R3: no Stripe API calls. We'll use the rulesStore directly with
|
|
865
|
+
// the card_id as the rule lookup key — the card metadata mapping is only
|
|
866
|
+
// available via Stripe. We look up the first rule that lists this card_id,
|
|
867
|
+
// or fall back to reading card metadata from a local cache.
|
|
868
|
+
//
|
|
869
|
+
// Practical approach: try to load rule by treating card_id as a potential
|
|
870
|
+
// rule_id lookup key, or query RulesStore.listRules() to find one
|
|
871
|
+
// matching this card. If not found, return default-deny.
|
|
872
|
+
//
|
|
873
|
+
// For maximum usefulness without a Stripe call, we support:
|
|
874
|
+
// - A card_rule_id arg (optional) to skip the card lookup
|
|
875
|
+
// - Fallback: search rules store for a rule with card_id matching this card
|
|
876
|
+
// - Final fallback: no rule found → default-deny with explanation
|
|
877
|
+
let rule = null;
|
|
878
|
+
let ruleName = 'unknown';
|
|
879
|
+
let ruleId = null;
|
|
880
|
+
// Check if caller provided an explicit rule ID override
|
|
881
|
+
const explicitRuleId = args.rule_id;
|
|
882
|
+
if (explicitRuleId) {
|
|
883
|
+
rule = await core_1.rulesStore.getRule(explicitRuleId);
|
|
884
|
+
if (rule) {
|
|
885
|
+
ruleId = explicitRuleId;
|
|
886
|
+
ruleName = rule.name ?? explicitRuleId;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
if (!rule) {
|
|
890
|
+
// Search the rules store for a rule associated with this card_id
|
|
891
|
+
const allRules = await core_1.rulesStore.listRules();
|
|
892
|
+
// Rules don't store card_id in the JSON file — they're referenced via
|
|
893
|
+
// Stripe card metadata. Without a Stripe call we can't resolve
|
|
894
|
+
// card → rule_id. Instead, if there's exactly one rule, use it as a
|
|
895
|
+
// best-effort heuristic (useful in dev/single-card setups).
|
|
896
|
+
// In production with multiple cards, the agent should pass rule_id explicitly.
|
|
897
|
+
if (allRules.length === 0) {
|
|
898
|
+
// No rules at all
|
|
899
|
+
return {
|
|
900
|
+
status: 'error',
|
|
901
|
+
message: 'No rules found in the store. Create a rule first using opencard_set_limits or rules store API.',
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
else if (allRules.length === 1) {
|
|
905
|
+
rule = allRules[0].rule;
|
|
906
|
+
ruleId = allRules[0].id;
|
|
907
|
+
ruleName = rule.name ?? ruleId;
|
|
908
|
+
}
|
|
909
|
+
else {
|
|
910
|
+
// Multiple rules exist and no rule_id was provided
|
|
911
|
+
const ruleList = allRules
|
|
912
|
+
.map((r) => `${r.id} (${r.rule.name || 'unnamed'})`)
|
|
913
|
+
.join(', ');
|
|
914
|
+
return {
|
|
915
|
+
status: 'error',
|
|
916
|
+
message: `Multiple rules found in the store and rule_id was not provided. Pass rule_id explicitly. Available rules: ${ruleList}`,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (!rule) {
|
|
921
|
+
return {
|
|
922
|
+
status: 'success',
|
|
923
|
+
would_approve: false,
|
|
924
|
+
reason: 'Could not resolve which rule applies to this card. This usually means: (1) the card has no opencard_rule_id in its Stripe metadata, or (2) there are multiple rules in the store and we can\'t pick one without a Stripe API call. Fix: pass rule_id explicitly, or assign a rule to the card via opencard_rule_id metadata.',
|
|
925
|
+
rule_name: null,
|
|
926
|
+
rule_id: null,
|
|
927
|
+
checks: {},
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
// ── Step 2: Get current daily/monthly spend from SQLite ───────────────────
|
|
931
|
+
let dailySpend = 0;
|
|
932
|
+
let monthlySpend = 0;
|
|
933
|
+
let spendDataNote = null;
|
|
934
|
+
try {
|
|
935
|
+
const db = new core_1.OpenCardDatabase();
|
|
936
|
+
const rawDb = db.getDb();
|
|
937
|
+
const now = new Date();
|
|
938
|
+
const todayMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
939
|
+
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
940
|
+
const todayStr = todayMidnight.toISOString().replace('T', ' ').slice(0, 19);
|
|
941
|
+
const monthStr = monthStart.toISOString().replace('T', ' ').slice(0, 19);
|
|
942
|
+
const todayRow = rawDb.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM authorizations WHERE card_id = ? AND status = 'approved' AND created_at >= ?`).get(cardId, todayStr);
|
|
943
|
+
const monthRow = rawDb.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM authorizations WHERE card_id = ? AND status = 'approved' AND created_at >= ?`).get(cardId, monthStr);
|
|
944
|
+
dailySpend = todayRow.total;
|
|
945
|
+
monthlySpend = monthRow.total;
|
|
946
|
+
db.close();
|
|
947
|
+
}
|
|
948
|
+
catch {
|
|
949
|
+
spendDataNote = 'unavailable — webhook server not running or DB not initialized';
|
|
950
|
+
// Continue with zeroed spend — run the check without spend context
|
|
951
|
+
}
|
|
952
|
+
// ── Step 3: Build a mock Stripe authorization object ──────────────────────
|
|
953
|
+
const mockAuth = {
|
|
954
|
+
id: `dry_run_${Date.now()}`,
|
|
955
|
+
object: 'issuing.authorization',
|
|
956
|
+
amount: amountCents,
|
|
957
|
+
approved: false,
|
|
958
|
+
currency: 'usd',
|
|
959
|
+
merchant_data: {
|
|
960
|
+
category: merchantCategory,
|
|
961
|
+
name: merchantName,
|
|
962
|
+
city: null,
|
|
963
|
+
country: null,
|
|
964
|
+
network_id: '',
|
|
965
|
+
postal_code: null,
|
|
966
|
+
state: null,
|
|
967
|
+
},
|
|
968
|
+
card: { id: cardId, metadata: {} },
|
|
969
|
+
metadata: {},
|
|
970
|
+
pending_request: null,
|
|
971
|
+
request_history: [],
|
|
972
|
+
status: 'pending',
|
|
973
|
+
created: Math.floor(Date.now() / 1000),
|
|
974
|
+
livemode: false,
|
|
975
|
+
network_data: null,
|
|
976
|
+
transactions: [],
|
|
977
|
+
verification_data: {
|
|
978
|
+
address_line1_check: 'not_provided',
|
|
979
|
+
address_postal_code_check: 'not_provided',
|
|
980
|
+
cvc_check: 'not_provided',
|
|
981
|
+
expiry_check: 'match',
|
|
982
|
+
},
|
|
983
|
+
wallet: null,
|
|
984
|
+
amount_details: null,
|
|
985
|
+
balance_transactions: [],
|
|
986
|
+
cardholder: null,
|
|
987
|
+
fleet: null,
|
|
988
|
+
fuel: null,
|
|
989
|
+
merchant_amount: amountCents,
|
|
990
|
+
merchant_currency: 'usd',
|
|
991
|
+
token: null,
|
|
992
|
+
};
|
|
993
|
+
// ── Step 4: Evaluate against the rules engine ─────────────────────────────
|
|
994
|
+
const decision = (0, core_1.evaluateAuthorization)(mockAuth, rule, {
|
|
995
|
+
dailySpend,
|
|
996
|
+
monthlySpend,
|
|
997
|
+
});
|
|
998
|
+
// ── Step 5: Build detailed checks breakdown ────────────────────────────────
|
|
999
|
+
const checks = {};
|
|
1000
|
+
if (rule.maxPerTransaction !== undefined) {
|
|
1001
|
+
checks.per_transaction = {
|
|
1002
|
+
limit: rule.maxPerTransaction,
|
|
1003
|
+
amount: amountCents,
|
|
1004
|
+
passed: amountCents <= rule.maxPerTransaction,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
if (rule.dailyLimit !== undefined) {
|
|
1008
|
+
checks.daily = {
|
|
1009
|
+
limit: rule.dailyLimit,
|
|
1010
|
+
spent_today: dailySpend,
|
|
1011
|
+
after_purchase: dailySpend + amountCents,
|
|
1012
|
+
passed: dailySpend + amountCents <= rule.dailyLimit,
|
|
1013
|
+
...(spendDataNote ? { spend_data: spendDataNote } : {}),
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
if (rule.monthlyLimit !== undefined) {
|
|
1017
|
+
checks.monthly = {
|
|
1018
|
+
limit: rule.monthlyLimit,
|
|
1019
|
+
spent_this_month: monthlySpend,
|
|
1020
|
+
after_purchase: monthlySpend + amountCents,
|
|
1021
|
+
passed: monthlySpend + amountCents <= rule.monthlyLimit,
|
|
1022
|
+
...(spendDataNote ? { spend_data: spendDataNote } : {}),
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
if (rule.allowedCategories && rule.allowedCategories.length > 0) {
|
|
1026
|
+
checks.category = {
|
|
1027
|
+
allowed: rule.allowedCategories,
|
|
1028
|
+
requested: merchantCategory || null,
|
|
1029
|
+
passed: !merchantCategory || rule.allowedCategories.includes(merchantCategory),
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
else if (rule.blockedCategories && rule.blockedCategories.length > 0) {
|
|
1033
|
+
checks.category = {
|
|
1034
|
+
blocked: rule.blockedCategories,
|
|
1035
|
+
requested: merchantCategory || null,
|
|
1036
|
+
passed: !merchantCategory || !rule.blockedCategories.includes(merchantCategory),
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
// ── Step 6: Include card description so agent can confirm right card ──────
|
|
1040
|
+
// Try to read description from Stripe metadata. Best-effort — if Stripe
|
|
1041
|
+
// call fails (no key set, test env, etc.), description is null.
|
|
1042
|
+
let cardDescription = null;
|
|
1043
|
+
const secretKeyForDesc = process.env.STRIPE_SECRET_KEY;
|
|
1044
|
+
if (secretKeyForDesc) {
|
|
1045
|
+
try {
|
|
1046
|
+
const stripe = new stripe_1.default(secretKeyForDesc, { apiVersion: '2023-10-16' });
|
|
1047
|
+
const stripeCard = await stripe.issuing.cards.retrieve(cardId);
|
|
1048
|
+
cardDescription = stripeCard.metadata?.opencard_description ?? null;
|
|
1049
|
+
}
|
|
1050
|
+
catch {
|
|
1051
|
+
// Not critical — proceed without description
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return {
|
|
1055
|
+
status: 'success',
|
|
1056
|
+
would_approve: decision.approved,
|
|
1057
|
+
reason: decision.reason,
|
|
1058
|
+
rule_name: ruleName,
|
|
1059
|
+
rule_id: ruleId,
|
|
1060
|
+
card_description: cardDescription,
|
|
1061
|
+
checks,
|
|
1062
|
+
...(spendDataNote && Object.keys(checks).length === 0 ? { spend_data: spendDataNote } : {}),
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
catch (err) {
|
|
1066
|
+
return { status: 'error', message: String(err) };
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* opencard_request_approval
|
|
1071
|
+
* Human-in-the-loop spending approval. Creates an approval request in SQLite,
|
|
1072
|
+
* notifies the operator (via stderr + optional webhook), then polls every 2s
|
|
1073
|
+
* until the human approves/denies or the timeout expires.
|
|
1074
|
+
*
|
|
1075
|
+
* Approval does NOT auto-execute the purchase — it returns permission only.
|
|
1076
|
+
* The agent then decides whether to proceed.
|
|
1077
|
+
*/
|
|
1078
|
+
async function handleRequestApproval(args) {
|
|
1079
|
+
try {
|
|
1080
|
+
const cardId = args.card_id;
|
|
1081
|
+
const amountCents = args.amount_cents;
|
|
1082
|
+
const merchantName = args.merchant_name;
|
|
1083
|
+
const reason = args.reason;
|
|
1084
|
+
const merchantCategory = args.merchant_category ?? null;
|
|
1085
|
+
const timeoutSeconds = typeof args.timeout_seconds === 'number' ? args.timeout_seconds : 300;
|
|
1086
|
+
if (!cardId)
|
|
1087
|
+
return { status: 'error', message: 'card_id is required' };
|
|
1088
|
+
if (typeof amountCents !== 'number' || amountCents < 0)
|
|
1089
|
+
return { status: 'error', message: 'amount_cents must be a non-negative number' };
|
|
1090
|
+
if (!merchantName)
|
|
1091
|
+
return { status: 'error', message: 'merchant_name is required' };
|
|
1092
|
+
if (!reason)
|
|
1093
|
+
return { status: 'error', message: 'reason is required' };
|
|
1094
|
+
// ── Create the approval request in SQLite ─────────────────────────────
|
|
1095
|
+
const db = (0, core_1.initDatabase)();
|
|
1096
|
+
const request = db.createApprovalRequest({
|
|
1097
|
+
card_id: cardId,
|
|
1098
|
+
amount: amountCents,
|
|
1099
|
+
merchant_name: merchantName,
|
|
1100
|
+
merchant_category: merchantCategory,
|
|
1101
|
+
reason,
|
|
1102
|
+
timeout_seconds: timeoutSeconds,
|
|
1103
|
+
});
|
|
1104
|
+
const requestId = request.id;
|
|
1105
|
+
const amountDollars = `$${(amountCents / 100).toFixed(2)}`;
|
|
1106
|
+
// ── Notify operator ───────────────────────────────────────────────────
|
|
1107
|
+
const notifyMsg = [
|
|
1108
|
+
``,
|
|
1109
|
+
`╔══════════════════════════════════════════════════════╗`,
|
|
1110
|
+
`║ 🔔 APPROVAL REQUEST — ${requestId}`,
|
|
1111
|
+
`╠══════════════════════════════════════════════════════╣`,
|
|
1112
|
+
`║ Amount: ${amountDollars}`,
|
|
1113
|
+
`║ Merchant: ${merchantName}${merchantCategory ? ` (${merchantCategory})` : ''}`,
|
|
1114
|
+
`║ Card: ${cardId}`,
|
|
1115
|
+
`║ Reason: ${reason}`,
|
|
1116
|
+
`║ Timeout: ${timeoutSeconds}s`,
|
|
1117
|
+
`╠══════════════════════════════════════════════════════╣`,
|
|
1118
|
+
`║ opencard approvals approve ${requestId}`,
|
|
1119
|
+
`║ opencard approvals deny ${requestId} --note "reason"`,
|
|
1120
|
+
`╚══════════════════════════════════════════════════════╝`,
|
|
1121
|
+
``,
|
|
1122
|
+
].join('\n');
|
|
1123
|
+
process.stderr.write(notifyMsg + '\n');
|
|
1124
|
+
// Fire webhook notification (non-blocking, don't fail if it errors)
|
|
1125
|
+
const webhookUrl = process.env.OPENCARD_APPROVAL_WEBHOOK_URL;
|
|
1126
|
+
if (webhookUrl) {
|
|
1127
|
+
const payload = {
|
|
1128
|
+
event: 'approval_request.created',
|
|
1129
|
+
request_id: requestId,
|
|
1130
|
+
card_id: cardId,
|
|
1131
|
+
amount_cents: amountCents,
|
|
1132
|
+
merchant_name: merchantName,
|
|
1133
|
+
merchant_category: merchantCategory,
|
|
1134
|
+
reason,
|
|
1135
|
+
timeout_seconds: timeoutSeconds,
|
|
1136
|
+
created_at: request.created_at,
|
|
1137
|
+
expires_at: request.expires_at,
|
|
1138
|
+
};
|
|
1139
|
+
fetch(webhookUrl, {
|
|
1140
|
+
method: 'POST',
|
|
1141
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1142
|
+
body: JSON.stringify(payload),
|
|
1143
|
+
}).catch(err => {
|
|
1144
|
+
process.stderr.write(`[OpenCard] Approval webhook notification failed: ${err}\n`);
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
// ── Poll until decided or expired ─────────────────────────────────────
|
|
1148
|
+
const pollIntervalMs = 2000;
|
|
1149
|
+
const deadlineMs = Date.now() + timeoutSeconds * 1000;
|
|
1150
|
+
while (Date.now() < deadlineMs) {
|
|
1151
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
1152
|
+
db.expireStaleApprovalRequests();
|
|
1153
|
+
const current = db.getApprovalRequest(requestId);
|
|
1154
|
+
if (!current) {
|
|
1155
|
+
db.close();
|
|
1156
|
+
return { status: 'error', message: `Approval request ${requestId} disappeared` };
|
|
1157
|
+
}
|
|
1158
|
+
if (current.status === 'approved') {
|
|
1159
|
+
db.close();
|
|
1160
|
+
return {
|
|
1161
|
+
status: 'approved',
|
|
1162
|
+
request_id: requestId,
|
|
1163
|
+
approved_by: current.decided_by,
|
|
1164
|
+
note: current.decision_note ?? null,
|
|
1165
|
+
approved_at: current.decided_at,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
if (current.status === 'denied') {
|
|
1169
|
+
db.close();
|
|
1170
|
+
return {
|
|
1171
|
+
status: 'denied',
|
|
1172
|
+
request_id: requestId,
|
|
1173
|
+
denied_by: current.decided_by,
|
|
1174
|
+
note: current.decision_note ?? null,
|
|
1175
|
+
denied_at: current.decided_at,
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
if (current.status === 'expired') {
|
|
1179
|
+
db.close();
|
|
1180
|
+
return {
|
|
1181
|
+
status: 'expired',
|
|
1182
|
+
request_id: requestId,
|
|
1183
|
+
message: `No response within ${timeoutSeconds} seconds`,
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
db.expireStaleApprovalRequests();
|
|
1188
|
+
db.close();
|
|
1189
|
+
return {
|
|
1190
|
+
status: 'expired',
|
|
1191
|
+
request_id: requestId,
|
|
1192
|
+
message: `No response within ${timeoutSeconds} seconds`,
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
catch (err) {
|
|
1196
|
+
return { status: 'error', message: String(err) };
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
//# sourceMappingURL=tools.js.map
|