@opencard-dev/core 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.
Files changed (53) hide show
  1. package/dist/db.d.ts +145 -0
  2. package/dist/db.d.ts.map +1 -0
  3. package/dist/db.js +373 -0
  4. package/dist/db.js.map +1 -0
  5. package/dist/errors.d.ts +72 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/errors.js +124 -0
  8. package/dist/errors.js.map +1 -0
  9. package/dist/index.d.ts +34 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +67 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/logger.d.ts +53 -0
  14. package/dist/logger.d.ts.map +1 -0
  15. package/dist/logger.js +109 -0
  16. package/dist/logger.js.map +1 -0
  17. package/dist/rules-engine.d.ts +159 -0
  18. package/dist/rules-engine.d.ts.map +1 -0
  19. package/dist/rules-engine.js +375 -0
  20. package/dist/rules-engine.js.map +1 -0
  21. package/dist/rules-store.d.ts +187 -0
  22. package/dist/rules-store.d.ts.map +1 -0
  23. package/dist/rules-store.js +291 -0
  24. package/dist/rules-store.js.map +1 -0
  25. package/dist/rules-validation.d.ts +54 -0
  26. package/dist/rules-validation.d.ts.map +1 -0
  27. package/dist/rules-validation.js +110 -0
  28. package/dist/rules-validation.js.map +1 -0
  29. package/dist/stripe-client.d.ts +154 -0
  30. package/dist/stripe-client.d.ts.map +1 -0
  31. package/dist/stripe-client.js +444 -0
  32. package/dist/stripe-client.js.map +1 -0
  33. package/dist/test-utils.d.ts +55 -0
  34. package/dist/test-utils.d.ts.map +1 -0
  35. package/dist/test-utils.js +91 -0
  36. package/dist/test-utils.js.map +1 -0
  37. package/dist/tracker.d.ts +130 -0
  38. package/dist/tracker.d.ts.map +1 -0
  39. package/dist/tracker.js +196 -0
  40. package/dist/tracker.js.map +1 -0
  41. package/dist/transaction-reconciler.d.ts +30 -0
  42. package/dist/transaction-reconciler.d.ts.map +1 -0
  43. package/dist/transaction-reconciler.js +131 -0
  44. package/dist/transaction-reconciler.js.map +1 -0
  45. package/dist/types.d.ts +194 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +7 -0
  48. package/dist/types.js.map +1 -0
  49. package/dist/webhooks.d.ts +121 -0
  50. package/dist/webhooks.d.ts.map +1 -0
  51. package/dist/webhooks.js +307 -0
  52. package/dist/webhooks.js.map +1 -0
  53. package/package.json +46 -0
@@ -0,0 +1,291 @@
1
+ "use strict";
2
+ /**
3
+ * Rules Store
4
+ * ===========
5
+ * Persistent storage for SpendRule objects, backed by a local JSON file.
6
+ *
7
+ * ─── Why not keep rules in Stripe metadata? ─────────────────────────────────
8
+ * Storing rules in Stripe card metadata (as we did in Phase 1) is a security
9
+ * problem: anyone with Stripe dashboard access can edit the rules directly,
10
+ * bypassing any access controls we might add. By moving rules to our own store,
11
+ * we control who can read or modify them — and Stripe metadata only holds a
12
+ * reference ID (opencard_rule_id), not the rules themselves.
13
+ *
14
+ * ─── Why a JSON file? ────────────────────────────────────────────────────────
15
+ * Phase 1 is all about getting something working and safe without adding
16
+ * operational complexity. A JSON file:
17
+ * - Survives process restarts (unlike in-memory)
18
+ * - Requires no database setup or migration
19
+ * - Is easy to inspect, back up, and version-control
20
+ * - Is fast enough for Phase 1 workloads (hundreds of rules, not millions)
21
+ *
22
+ * Phase 2 will replace this with a proper database when we need multi-instance
23
+ * support or higher write throughput.
24
+ *
25
+ * ─── File format ─────────────────────────────────────────────────────────────
26
+ * The file is a JSON object: { [ruleId]: SpendRule }
27
+ * Example:
28
+ * {
29
+ * "rule_01HX9K2Z3A4B5C6D7E8F9G0H1J": { "dailyLimit": 10000, "onFailure": "decline" },
30
+ * "rule_01HX9K2Z3A4B5C6D7E8F9G0H1K": { "monthlyLimit": 50000 }
31
+ * }
32
+ *
33
+ * ─── ID format ───────────────────────────────────────────────────────────────
34
+ * IDs use the format `rule_<timestamp_hex><random_hex>` — lexicographically
35
+ * sortable (newer rules sort after older ones) and globally unique without
36
+ * a central counter. We implement this without external dependencies using
37
+ * Node.js's built-in crypto module.
38
+ *
39
+ * ─── Thread safety ───────────────────────────────────────────────────────────
40
+ * We serialize all writes through a simple async queue (each write awaits the
41
+ * previous one). This isn't needed for single-threaded Node.js, but it makes
42
+ * the behavior explicit and protects against future async-write bugs.
43
+ */
44
+ var __importDefault = (this && this.__importDefault) || function (mod) {
45
+ return (mod && mod.__esModule) ? mod : { "default": mod };
46
+ };
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.rulesStore = exports.RulesStore = void 0;
49
+ const promises_1 = __importDefault(require("fs/promises"));
50
+ const path_1 = __importDefault(require("path"));
51
+ const crypto_1 = __importDefault(require("crypto"));
52
+ const rules_validation_1 = require("./rules-validation");
53
+ // ─── RulesStore class ─────────────────────────────────────────────────────────
54
+ class RulesStore {
55
+ /**
56
+ * Path to the JSON file where rules are stored.
57
+ * Configurable via OPENCARD_RULES_PATH env var.
58
+ * Defaults to ./opencard-rules.json relative to the process working directory.
59
+ */
60
+ filePath;
61
+ /**
62
+ * Internal write queue: we chain writes so concurrent callers don't
63
+ * corrupt the file by reading stale contents and overwriting each other.
64
+ */
65
+ writeQueue = Promise.resolve();
66
+ constructor(filePath) {
67
+ // Prefer explicit argument, then env var, then default.
68
+ // path.resolve() converts relative paths to absolute, using process.cwd().
69
+ this.filePath = filePath
70
+ || process.env.OPENCARD_RULES_PATH
71
+ || path_1.default.resolve(process.cwd(), 'opencard-rules.json');
72
+ }
73
+ // ─── ID generation ─────────────────────────────────────────────────────────
74
+ /**
75
+ * Generates a unique, sortable rule ID.
76
+ *
77
+ * Format: `rule_<timestamp_ms_hex><random_8_hex_chars>`
78
+ * Example: `rule_018f1a2b3c4d5e6f7a8b9c0d`
79
+ *
80
+ * - The timestamp prefix makes IDs sort chronologically (newest last in
81
+ * a lexicographic sort), which is useful when listing rules.
82
+ * - The random suffix prevents collisions even if two rules are created
83
+ * in the same millisecond.
84
+ * - No external library needed — uses Node.js built-in crypto.
85
+ */
86
+ generateId() {
87
+ const timestampHex = Date.now().toString(16).padStart(12, '0');
88
+ const randomHex = crypto_1.default.randomBytes(6).toString('hex');
89
+ return `rule_${timestampHex}${randomHex}`;
90
+ }
91
+ // ─── File I/O ───────────────────────────────────────────────────────────────
92
+ /**
93
+ * Reads the rules file from disk and parses it.
94
+ *
95
+ * If the file doesn't exist yet, returns an empty object (not an error) —
96
+ * the file will be created on the first write.
97
+ *
98
+ * If the file exists but contains invalid JSON, throws an error. This
99
+ * shouldn't happen in normal operation (we always write valid JSON), but
100
+ * could happen if the file was manually edited.
101
+ */
102
+ async readFile() {
103
+ try {
104
+ const raw = await promises_1.default.readFile(this.filePath, 'utf8');
105
+ return JSON.parse(raw);
106
+ }
107
+ catch (err) {
108
+ // ENOENT = file not found — that's OK, just means no rules stored yet
109
+ if (err.code === 'ENOENT') {
110
+ return {};
111
+ }
112
+ // Any other error (permission denied, malformed JSON, etc.) is a real problem
113
+ throw new Error(`[RulesStore] Failed to read rules file at ${this.filePath}: ${String(err)}`);
114
+ }
115
+ }
116
+ /**
117
+ * Writes the entire rules object to disk, atomically.
118
+ *
119
+ * We write to a temp file first, then rename it into place. This avoids
120
+ * leaving a half-written file if the process crashes mid-write.
121
+ *
122
+ * All writes are serialized through the writeQueue to prevent concurrent
123
+ * writes from racing each other.
124
+ */
125
+ async writeFile(contents) {
126
+ // Chain this write onto the end of the queue
127
+ this.writeQueue = this.writeQueue.then(async () => {
128
+ const tmpPath = `${this.filePath}.tmp`;
129
+ const json = JSON.stringify(contents, null, 2); // pretty-print for human readability
130
+ try {
131
+ // Ensure the parent directory exists (in case OPENCARD_RULES_PATH points
132
+ // to a dir that hasn't been created yet)
133
+ await promises_1.default.mkdir(path_1.default.dirname(this.filePath), { recursive: true });
134
+ // Write to temp file first
135
+ await promises_1.default.writeFile(tmpPath, json, 'utf8');
136
+ // Rename into place (atomic on POSIX systems when same filesystem)
137
+ await promises_1.default.rename(tmpPath, this.filePath);
138
+ }
139
+ catch (err) {
140
+ // Clean up temp file if we can (ignore errors from cleanup itself)
141
+ await promises_1.default.unlink(tmpPath).catch(() => { });
142
+ throw new Error(`[RulesStore] Failed to write rules file at ${this.filePath}: ${String(err)}`);
143
+ }
144
+ });
145
+ // Wait for this specific write to complete before returning
146
+ return this.writeQueue;
147
+ }
148
+ // ─── CRUD operations ────────────────────────────────────────────────────────
149
+ /**
150
+ * Creates a new rule in the store.
151
+ *
152
+ * Validates the rule before writing — throws if validation fails.
153
+ * This is the right place to reject bad rules so they never make it
154
+ * into the store and can never affect a live transaction.
155
+ *
156
+ * @param rule - The spend rule to store
157
+ * @returns The generated rule ID (e.g. "rule_018f1a2b3c4d5e6f7a8b9c0d")
158
+ *
159
+ * @throws Error if the rule fails validation
160
+ *
161
+ * @example
162
+ * const ruleId = await rulesStore.createRule({
163
+ * dailyLimit: 10000, // $100/day
164
+ * onFailure: 'decline',
165
+ * });
166
+ * // ruleId → "rule_018f1a2b3c4d5e6f7a8b9c0d"
167
+ */
168
+ async createRule(rule) {
169
+ // Validate before writing — reject invalid rules with clear error messages
170
+ const validation = (0, rules_validation_1.validateRule)(rule);
171
+ if (!validation.valid) {
172
+ throw new Error(`[RulesStore] Invalid rule: ${validation.errors.join('; ')}`);
173
+ }
174
+ const ruleId = this.generateId();
175
+ const contents = await this.readFile();
176
+ // Sanity check: generated ID should never collide, but just in case
177
+ if (contents[ruleId]) {
178
+ throw new Error(`[RulesStore] ID collision on ${ruleId} — this should never happen`);
179
+ }
180
+ contents[ruleId] = rule;
181
+ await this.writeFile(contents);
182
+ console.log(`[RulesStore] Created rule ${ruleId}`);
183
+ return ruleId;
184
+ }
185
+ /**
186
+ * Looks up a rule by its ID.
187
+ *
188
+ * Returns null (not an error) if the rule doesn't exist. This is important
189
+ * in the webhook path: if a card references a rule ID that's been deleted,
190
+ * the webhook handler should fall back to default-deny, not crash.
191
+ *
192
+ * @param ruleId - The rule ID to look up
193
+ * @returns The SpendRule, or null if not found
194
+ *
195
+ * @example
196
+ * const rule = await rulesStore.getRule('rule_018f1a2b...');
197
+ * if (rule === null) {
198
+ * // rule was deleted or ID is wrong — use default-deny
199
+ * }
200
+ */
201
+ async getRule(ruleId) {
202
+ const contents = await this.readFile();
203
+ return contents[ruleId] ?? null;
204
+ }
205
+ /**
206
+ * Updates an existing rule.
207
+ *
208
+ * Validates the new rule before writing. The update replaces the entire
209
+ * rule — it's not a partial merge. Pass the full updated rule object.
210
+ *
211
+ * @throws Error if the rule doesn't exist (can't update what isn't there)
212
+ * @throws Error if the new rule fails validation
213
+ *
214
+ * @example
215
+ * await rulesStore.updateRule('rule_018f1a2b...', {
216
+ * dailyLimit: 20000, // raised from $100 to $200
217
+ * onFailure: 'decline',
218
+ * });
219
+ */
220
+ async updateRule(ruleId, rule) {
221
+ // Validate before writing
222
+ const validation = (0, rules_validation_1.validateRule)(rule);
223
+ if (!validation.valid) {
224
+ throw new Error(`[RulesStore] Invalid rule: ${validation.errors.join('; ')}`);
225
+ }
226
+ const contents = await this.readFile();
227
+ if (!contents[ruleId]) {
228
+ throw new Error(`[RulesStore] Rule ${ruleId} not found — cannot update a rule that doesn't exist`);
229
+ }
230
+ contents[ruleId] = rule;
231
+ await this.writeFile(contents);
232
+ console.log(`[RulesStore] Updated rule ${ruleId}`);
233
+ }
234
+ /**
235
+ * Deletes a rule from the store.
236
+ *
237
+ * Note: this does NOT update any cards that reference this rule.
238
+ * Cards referencing a deleted rule ID will fall back to default-deny
239
+ * in the webhook handler (by design — it's better to be safe).
240
+ *
241
+ * If the rule doesn't exist, this is a no-op (not an error).
242
+ *
243
+ * @param ruleId - The rule ID to delete
244
+ */
245
+ async deleteRule(ruleId) {
246
+ const contents = await this.readFile();
247
+ if (!contents[ruleId]) {
248
+ // No-op — deleting a non-existent rule is fine
249
+ console.warn(`[RulesStore] deleteRule called for non-existent rule ${ruleId} — no-op`);
250
+ return;
251
+ }
252
+ delete contents[ruleId];
253
+ await this.writeFile(contents);
254
+ console.log(`[RulesStore] Deleted rule ${ruleId}`);
255
+ }
256
+ /**
257
+ * Lists all rules in the store.
258
+ *
259
+ * Returns them sorted by ID (which is chronological since IDs are
260
+ * timestamp-prefixed). Newest rules appear last.
261
+ *
262
+ * @returns Array of { id, rule } objects, sorted by creation order
263
+ *
264
+ * @example
265
+ * const rules = await rulesStore.listRules();
266
+ * for (const { id, rule } of rules) {
267
+ * console.log(`${id}: ${rule.name ?? 'unnamed'}`);
268
+ * }
269
+ */
270
+ async listRules() {
271
+ const contents = await this.readFile();
272
+ return Object.entries(contents)
273
+ .sort(([a], [b]) => a.localeCompare(b)) // sort by ID = sort by creation time
274
+ .map(([id, rule]) => ({ id, rule }));
275
+ }
276
+ }
277
+ exports.RulesStore = RulesStore;
278
+ // ─── Singleton ────────────────────────────────────────────────────────────────
279
+ /**
280
+ * Module-level singleton instance of RulesStore.
281
+ *
282
+ * Export and use this everywhere, just like the `tracker` singleton.
283
+ * This ensures all parts of the system share the same file path and
284
+ * write queue — critical for avoiding concurrent write corruption.
285
+ *
286
+ * @example
287
+ * import { rulesStore } from '@opencard-dev/core';
288
+ * const ruleId = await rulesStore.createRule({ dailyLimit: 10000 });
289
+ */
290
+ exports.rulesStore = new RulesStore();
291
+ //# sourceMappingURL=rules-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rules-store.js","sourceRoot":"","sources":["../src/rules-store.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Rule Validation
3
+ * ===============
4
+ * Validates SpendRule objects before they're written to the rules store.
5
+ *
6
+ * ─── Why validate at write time? ────────────────────────────────────────────
7
+ * We check rules when they're created or updated — not at authorization time.
8
+ * This means:
9
+ * 1. If you write a bad rule, you find out immediately (not at 2am when a
10
+ * card transaction fails in a confusing way).
11
+ * 2. The authorization path (hot path, ~2 second window) stays fast and simple.
12
+ * 3. We can give detailed error messages to whoever is creating the rule,
13
+ * not bury them in webhook logs.
14
+ *
15
+ * ─── What we validate ────────────────────────────────────────────────────────
16
+ * - Numeric limits must be positive integers (cents, so negative = nonsense)
17
+ * - Category arrays must actually be arrays of strings
18
+ * - onFailure must be one of the known action strings
19
+ *
20
+ * ─── What we don't validate ──────────────────────────────────────────────────
21
+ * We don't validate category strings themselves (i.e., we don't check if
22
+ * "grocery" is a real Stripe MCC category). That would require a lookup table
23
+ * and would break if Stripe adds new categories. Better to allow unknown
24
+ * categories and just have them never match.
25
+ */
26
+ import { SpendRule } from './types';
27
+ /**
28
+ * Result of a rule validation check.
29
+ * `valid: true` means the rule is safe to store.
30
+ * `valid: false` means there are errors — check the `errors` array.
31
+ */
32
+ export interface ValidationResult {
33
+ valid: boolean;
34
+ errors: string[];
35
+ }
36
+ /**
37
+ * Validates a SpendRule before it's written to the rules store.
38
+ *
39
+ * Collects ALL errors at once (instead of failing on the first one),
40
+ * so callers get a complete picture of what's wrong in a single call.
41
+ *
42
+ * @param rule - The rule to validate
43
+ * @returns { valid, errors } — if valid is false, errors has at least one entry
44
+ *
45
+ * @example
46
+ * const result = validateRule({ dailyLimit: -500, onFailure: 'explode' });
47
+ * // result.valid === false
48
+ * // result.errors === [
49
+ * // "dailyLimit must be a positive number (in cents), got -500",
50
+ * // "onFailure must be one of: decline, alert, pause — got 'explode'"
51
+ * // ]
52
+ */
53
+ export declare function validateRule(rule: SpendRule): ValidationResult;
54
+ //# sourceMappingURL=rules-validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rules-validation.d.ts","sourceRoot":"","sources":["../src/rules-validation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEpC;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AA+BD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,SAAS,GAAG,gBAAgB,CAgD9D"}
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ /**
3
+ * Rule Validation
4
+ * ===============
5
+ * Validates SpendRule objects before they're written to the rules store.
6
+ *
7
+ * ─── Why validate at write time? ────────────────────────────────────────────
8
+ * We check rules when they're created or updated — not at authorization time.
9
+ * This means:
10
+ * 1. If you write a bad rule, you find out immediately (not at 2am when a
11
+ * card transaction fails in a confusing way).
12
+ * 2. The authorization path (hot path, ~2 second window) stays fast and simple.
13
+ * 3. We can give detailed error messages to whoever is creating the rule,
14
+ * not bury them in webhook logs.
15
+ *
16
+ * ─── What we validate ────────────────────────────────────────────────────────
17
+ * - Numeric limits must be positive integers (cents, so negative = nonsense)
18
+ * - Category arrays must actually be arrays of strings
19
+ * - onFailure must be one of the known action strings
20
+ *
21
+ * ─── What we don't validate ──────────────────────────────────────────────────
22
+ * We don't validate category strings themselves (i.e., we don't check if
23
+ * "grocery" is a real Stripe MCC category). That would require a lookup table
24
+ * and would break if Stripe adds new categories. Better to allow unknown
25
+ * categories and just have them never match.
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.validateRule = validateRule;
29
+ /**
30
+ * The three valid values for onFailure.
31
+ * - 'decline': reject the transaction immediately
32
+ * - 'alert': approve but send an alert (Phase 2 feature)
33
+ * - 'pause': pause the card after violation (blocks future transactions)
34
+ */
35
+ const VALID_ON_FAILURE_VALUES = ['decline', 'alert', 'pause'];
36
+ /**
37
+ * The numeric fields in SpendRule that must be positive numbers if present.
38
+ * All are in cents (USD). Zero doesn't make sense as a limit — it would mean
39
+ * "allow spending of $0" which is effectively a declined card.
40
+ */
41
+ const NUMERIC_LIMIT_FIELDS = [
42
+ 'dailyLimit',
43
+ 'monthlyLimit',
44
+ 'maxPerTransaction',
45
+ 'approvalThreshold',
46
+ ];
47
+ /**
48
+ * The array fields in SpendRule that must be string arrays if present.
49
+ * These hold merchant category codes (MCCs) like "grocery", "transit", etc.
50
+ */
51
+ const CATEGORY_ARRAY_FIELDS = [
52
+ 'allowedCategories',
53
+ 'blockedCategories',
54
+ ];
55
+ /**
56
+ * Validates a SpendRule before it's written to the rules store.
57
+ *
58
+ * Collects ALL errors at once (instead of failing on the first one),
59
+ * so callers get a complete picture of what's wrong in a single call.
60
+ *
61
+ * @param rule - The rule to validate
62
+ * @returns { valid, errors } — if valid is false, errors has at least one entry
63
+ *
64
+ * @example
65
+ * const result = validateRule({ dailyLimit: -500, onFailure: 'explode' });
66
+ * // result.valid === false
67
+ * // result.errors === [
68
+ * // "dailyLimit must be a positive number (in cents), got -500",
69
+ * // "onFailure must be one of: decline, alert, pause — got 'explode'"
70
+ * // ]
71
+ */
72
+ function validateRule(rule) {
73
+ const errors = [];
74
+ // ── Check numeric limit fields ──────────────────────────────────────────────
75
+ for (const field of NUMERIC_LIMIT_FIELDS) {
76
+ const value = rule[field];
77
+ if (value !== undefined) {
78
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
79
+ errors.push(`${field} must be a positive number (in cents), got ${JSON.stringify(value)}`);
80
+ }
81
+ }
82
+ }
83
+ // ── Check category array fields ─────────────────────────────────────────────
84
+ for (const field of CATEGORY_ARRAY_FIELDS) {
85
+ const value = rule[field];
86
+ if (value !== undefined) {
87
+ if (!Array.isArray(value)) {
88
+ errors.push(`${field} must be an array of strings, got ${typeof value}`);
89
+ }
90
+ else {
91
+ // All elements must be strings
92
+ const nonStrings = value.filter((v) => typeof v !== 'string');
93
+ if (nonStrings.length > 0) {
94
+ errors.push(`${field} must contain only strings — found ${nonStrings.length} non-string value(s): ${JSON.stringify(nonStrings)}`);
95
+ }
96
+ }
97
+ }
98
+ }
99
+ // ── Check onFailure ─────────────────────────────────────────────────────────
100
+ if (rule.onFailure !== undefined) {
101
+ if (!VALID_ON_FAILURE_VALUES.includes(rule.onFailure)) {
102
+ errors.push(`onFailure must be one of: ${VALID_ON_FAILURE_VALUES.join(', ')} — got '${rule.onFailure}'`);
103
+ }
104
+ }
105
+ return {
106
+ valid: errors.length === 0,
107
+ errors,
108
+ };
109
+ }
110
+ //# sourceMappingURL=rules-validation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rules-validation.js","sourceRoot":"","sources":["../src/rules-validation.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;;AA4DH,oCAgDC;AA9FD;;;;;GAKG;AACH,MAAM,uBAAuB,GAAG,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,CAAU,CAAC;AAEvE;;;;GAIG;AACH,MAAM,oBAAoB,GAAG;IAC3B,YAAY;IACZ,cAAc;IACd,mBAAmB;IACnB,mBAAmB;CACX,CAAC;AAEX;;;GAGG;AACH,MAAM,qBAAqB,GAAG;IAC5B,mBAAmB;IACnB,mBAAmB;CACX,CAAC;AAEX;;;;;;;;;;;;;;;;GAgBG;AACH,SAAgB,YAAY,CAAC,IAAe;IAC1C,MAAM,MAAM,GAAa,EAAE,CAAC;IAE5B,+EAA+E;IAC/E,KAAK,MAAM,KAAK,IAAI,oBAAoB,EAAE,CAAC;QACzC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC;QAC1B,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YACxB,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,EAAE,CAAC;gBACvE,MAAM,CAAC,IAAI,CACT,GAAG,KAAK,8CAA8C,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CAC9E,CAAC;YACJ,CAAC;QACH,CAAC;IACH,CAAC;IAED,+EAA+E;IAC/E,KAAK,MAAM,KAAK,IAAI,qBAAqB,EAAE,CAAC;QAC1C,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC;QAC1B,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YACxB,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;gBAC1B,MAAM,CAAC,IAAI,CACT,GAAG,KAAK,qCAAqC,OAAO,KAAK,EAAE,CAC5D,CAAC;YACJ,CAAC;iBAAM,CAAC;gBACN,+BAA+B;gBAC/B,MAAM,UAAU,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC;gBAC9D,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;oBAC1B,MAAM,CAAC,IAAI,CACT,GAAG,KAAK,sCAAsC,UAAU,CAAC,MAAM,yBAAyB,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC,EAAE,CACrH,CAAC;gBACJ,CAAC;YACH,CAAC;QACH,CAAC;IACH,CAAC;IAED,+EAA+E;IAC/E,IAAI,IAAI,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;QACjC,IAAI,CAAC,uBAAuB,CAAC,QAAQ,CAAC,IAAI,CAAC,SAAmD,CAAC,EAAE,CAAC;YAChG,MAAM,CAAC,IAAI,CACT,6BAA6B,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,IAAI,CAAC,SAAS,GAAG,CAC5F,CAAC;QACJ,CAAC;IACH,CAAC;IAED,OAAO;QACL,KAAK,EAAE,MAAM,CAAC,MAAM,KAAK,CAAC;QAC1B,MAAM;KACP,CAAC;AACJ,CAAC"}
@@ -0,0 +1,154 @@
1
+ /**
2
+ * StripeClient
3
+ * ============
4
+ * A clean, typed wrapper around Stripe's Issuing API.
5
+ *
6
+ * Stripe's Issuing product lets you create virtual (and physical) payment cards
7
+ * programmatically. OpenCard uses it to give AI agents their own cards with
8
+ * spending controls attached.
9
+ *
10
+ * ─── Why a wrapper? ─────────────────────────────────────────────────────────
11
+ * The raw Stripe SDK is powerful but returns Stripe's own types, which don't
12
+ * always match what OpenCard needs. This wrapper:
13
+ * - Converts Stripe's snake_case fields to camelCase (matches our TypeScript style)
14
+ * - Maps Stripe's types to our internal interfaces (Card, Cardholder, etc.)
15
+ * - Centralizes error handling in one place
16
+ * - Makes the API surface smaller and easier to understand
17
+ *
18
+ * ─── Stripe API version ─────────────────────────────────────────────────────
19
+ * We pin to "2023-10-16". If you upgrade the Stripe SDK, check if the API
20
+ * version needs updating and whether any field names/types changed.
21
+ *
22
+ * ─── Test vs. Live mode ─────────────────────────────────────────────────────
23
+ * Stripe uses the key prefix to determine mode:
24
+ * sk_test_... → test mode (no real money, safe to experiment)
25
+ * sk_live_... → live mode (REAL money — be careful!)
26
+ * Never commit a live key to git.
27
+ */
28
+ import { OpenCardConfig, Card, Cardholder, BillingDetails, CardCreateOptions, Transaction, CardBalance, TransactionQueryOptions, Authorization, SpendingLimits } from './types';
29
+ export declare class StripeClient {
30
+ /** The underlying Stripe SDK instance. Use this for any Stripe calls not yet wrapped here. */
31
+ private stripe;
32
+ /** Stored config so we can reference it in later operations. */
33
+ private config;
34
+ /**
35
+ * Creates a new StripeClient.
36
+ *
37
+ * @param config - Optional configuration. If omitted, reads from environment variables.
38
+ *
39
+ * @throws Error if no API key is found (neither in config nor in STRIPE_SECRET_KEY env var)
40
+ *
41
+ * @example
42
+ * // Use environment variable (recommended for production)
43
+ * const client = new StripeClient();
44
+ *
45
+ * // Pass key directly (good for testing)
46
+ * const client = new StripeClient({ secretKey: 'sk_test_...' });
47
+ */
48
+ constructor(config?: OpenCardConfig);
49
+ /**
50
+ * Creates a new cardholder in Stripe Issuing.
51
+ *
52
+ * A cardholder is the entity that "owns" the cards. In a typical OpenCard
53
+ * setup, you'd create one cardholder per agent or per project, then create
54
+ * multiple cards under that cardholder.
55
+ *
56
+ * Stripe requires a billing address for cardholders in most configurations.
57
+ * In test mode this requirement may be relaxed.
58
+ *
59
+ * @param name - Display name for the cardholder (e.g. "Atlas Agent", "Research Bot")
60
+ * @param email - Contact email (used by Stripe for notifications)
61
+ * @param billing - Billing address (required for live mode)
62
+ * @returns The created Cardholder object
63
+ */
64
+ createCardholder(name: string, email: string, billing?: BillingDetails): Promise<Cardholder>;
65
+ /**
66
+ * Creates a new virtual card for an agent.
67
+ *
68
+ * The card is issued under the specified cardholder. Spending limits can be
69
+ * applied either via Stripe's native spending_controls (enforced at the
70
+ * Stripe level, last-resort safeguard) or via OpenCard's rules engine
71
+ * (enforced via the webhook server, smarter but requires the server to be running).
72
+ *
73
+ * The `agentName` is stored in card metadata so you can identify which
74
+ * agent this card belongs to when reviewing Stripe dashboard or logs.
75
+ *
76
+ * @param cardholderId - The Stripe cardholder ID (e.g. "ich_abc123")
77
+ * @param options - Card creation options (name, rules, status, metadata)
78
+ * @returns The created Card object
79
+ */
80
+ createCard(cardholderId: string, options: CardCreateOptions): Promise<Card>;
81
+ /**
82
+ * Retrieves a card by its Stripe card ID.
83
+ * Useful for checking current status, limits, and metadata.
84
+ *
85
+ * @param cardId - Stripe card ID (e.g. "ic_abc123")
86
+ */
87
+ getCard(cardId: string): Promise<Card>;
88
+ /**
89
+ * Pauses a card by setting its status to 'inactive'.
90
+ *
91
+ * A paused card will decline all new charges but can be resumed later.
92
+ * This is different from canceling — canceled cards cannot be reactivated.
93
+ *
94
+ * Use this when an agent has violated rules and needs to be stopped temporarily,
95
+ * or when you want to manually review spending before re-enabling.
96
+ *
97
+ * @param cardId - Stripe card ID to pause
98
+ */
99
+ pauseCard(cardId: string): Promise<Card>;
100
+ /**
101
+ * Resumes a paused card by setting its status back to 'active'.
102
+ * The card will accept new charges again immediately after this call.
103
+ *
104
+ * @param cardId - Stripe card ID to resume
105
+ */
106
+ resumeCard(cardId: string): Promise<Card>;
107
+ /**
108
+ * Updates the spending limits on a card.
109
+ *
110
+ * These are Stripe-native limits (not OpenCard rules). They're enforced at
111
+ * the Stripe level regardless of whether our webhook server is running.
112
+ * Good for setting hard floors that can never be bypassed.
113
+ *
114
+ * @param cardId - Stripe card ID
115
+ * @param limits - Array of spending limit objects
116
+ */
117
+ setSpendingLimits(cardId: string, limits: SpendingLimits[]): Promise<Card>;
118
+ /**
119
+ * Retrieves the transaction history for a card.
120
+ *
121
+ * Note: Stripe Issuing transactions are distinct from regular Stripe charges.
122
+ * They represent captured spending on issued cards.
123
+ *
124
+ * Note on Stripe v14 Transaction type: The `status` and `decline_reason` fields
125
+ * don't exist on Issuing.Transaction in the v14 type definitions (the transaction
126
+ * object represents *completed* captures, not pending authorizations). For
127
+ * pending/declined status, use the Issuing.Authorization type instead.
128
+ *
129
+ * @param cardId - Stripe card ID
130
+ * @param options - Optional filters (limit, date range, etc.)
131
+ */
132
+ getTransactions(cardId: string, options?: TransactionQueryOptions): Promise<Transaction[]>;
133
+ /**
134
+ * Returns a balance snapshot for this account.
135
+ *
136
+ * Note: Stripe Issuing doesn't expose a per-card balance API. The "balance"
137
+ * for virtual cards is determined by your spending limits, not a pre-loaded
138
+ * amount. This is a stub that returns zeroes.
139
+ *
140
+ * Phase 2 will compute this by summing transactions against spending limits.
141
+ */
142
+ getBalance(): Promise<CardBalance>;
143
+ /**
144
+ * Returns pending authorizations for a card.
145
+ *
146
+ * Stripe doesn't provide a REST API for pending authorizations — they're
147
+ * delivered exclusively via webhooks. This stub exists for API completeness.
148
+ * Phase 1 uses the webhook server for real-time authorization handling.
149
+ *
150
+ * @param _cardId - Card ID (unused in Phase 1)
151
+ */
152
+ getPendingAuthorizations(_cardId: string): Promise<Authorization[]>;
153
+ }
154
+ //# sourceMappingURL=stripe-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stripe-client.d.ts","sourceRoot":"","sources":["../src/stripe-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAGH,OAAO,EACL,cAAc,EACd,IAAI,EACJ,UAAU,EACV,cAAc,EACd,iBAAiB,EACjB,WAAW,EACX,WAAW,EACX,uBAAuB,EACvB,aAAa,EACb,cAAc,EACf,MAAM,SAAS,CAAC;AAIjB,qBAAa,YAAY;IACvB,8FAA8F;IAC9F,OAAO,CAAC,MAAM,CAAS;IACvB,gEAAgE;IAChE,OAAO,CAAC,MAAM,CAA2B;IAEzC;;;;;;;;;;;;;OAaG;gBACS,MAAM,GAAE,cAAmB;IAqCvC;;;;;;;;;;;;;;OAcG;IACG,gBAAgB,CACpB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,OAAO,CAAC,EAAE,cAAc,GACvB,OAAO,CAAC,UAAU,CAAC;IAiDtB;;;;;;;;;;;;;;OAcG;IACG,UAAU,CACd,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,IAAI,CAAC;IA8DhB;;;;;OAKG;IACG,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAc5C;;;;;;;;;;OAUG;IACG,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAgB9C;;;;;OAKG;IACG,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAgB/C;;;;;;;;;OASG;IACG,iBAAiB,CACrB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,cAAc,EAAE,GACvB,OAAO,CAAC,IAAI,CAAC;IAwBhB;;;;;;;;;;;;;OAaG;IACG,eAAe,CACnB,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,uBAAuB,GAChC,OAAO,CAAC,WAAW,EAAE,CAAC;IAkCzB;;;;;;;;OAQG;IACG,UAAU,IAAI,OAAO,CAAC,WAAW,CAAC;IASxC;;;;;;;;OAQG;IACG,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC;CAM1E"}