spaps 0.3.0 → 0.3.2

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/bin/spaps.js CHANGED
@@ -34,7 +34,7 @@ program
34
34
  program
35
35
  .command('local')
36
36
  .description('Start local SPAPS server (no API keys required!)')
37
- .option('-p, --port <port>', 'Port to run on', '3300')
37
+ .option('-p, --port <port>', 'Port to run on', '3456')
38
38
  .option('-o, --open', 'Open browser automatically', false)
39
39
  .option('--json', 'Output in JSON format')
40
40
  .action(async (options, command) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spaps",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Sweet Potato Authentication & Payment Service CLI - Zero-config local development and project scaffolding",
5
5
  "main": "bin/spaps.js",
6
6
  "bin": {
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Local Admin Management for SPAPS
3
+ * Provides product, customer, and order management in local mode
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { v4: uuidv4 } = require('uuid');
9
+
10
+ class LocalAdminManager {
11
+ constructor(dataDir = './.spaps') {
12
+ this.dataDir = dataDir;
13
+ this.ensureDataDir();
14
+ this.loadData();
15
+ }
16
+
17
+ ensureDataDir() {
18
+ if (!fs.existsSync(this.dataDir)) {
19
+ fs.mkdirSync(this.dataDir, { recursive: true });
20
+ }
21
+ }
22
+
23
+ loadData() {
24
+ // Load or create products
25
+ this.productsFile = path.join(this.dataDir, 'products.json');
26
+ if (fs.existsSync(this.productsFile)) {
27
+ this.products = JSON.parse(fs.readFileSync(this.productsFile, 'utf8'));
28
+ } else {
29
+ this.products = this.getDefaultProducts();
30
+ this.saveProducts();
31
+ }
32
+
33
+ // Load or create orders
34
+ this.ordersFile = path.join(this.dataDir, 'orders.json');
35
+ if (fs.existsSync(this.ordersFile)) {
36
+ this.orders = JSON.parse(fs.readFileSync(this.ordersFile, 'utf8'));
37
+ } else {
38
+ this.orders = [];
39
+ this.saveOrders();
40
+ }
41
+
42
+ // Load or create customers
43
+ this.customersFile = path.join(this.dataDir, 'customers.json');
44
+ if (fs.existsSync(this.customersFile)) {
45
+ this.customers = JSON.parse(fs.readFileSync(this.customersFile, 'utf8'));
46
+ } else {
47
+ this.customers = [];
48
+ this.saveCustomers();
49
+ }
50
+ }
51
+
52
+ getDefaultProducts() {
53
+ return [
54
+ {
55
+ id: 'prod_local_validate',
56
+ price_id: 'price_local_validate',
57
+ name: 'Validate Tier',
58
+ description: 'Landing page with data capture and analytics',
59
+ price: 50000,
60
+ currency: 'usd',
61
+ features: [
62
+ 'Landing page',
63
+ 'Data capture setup',
64
+ 'Analytics integration',
65
+ 'Deployment'
66
+ ],
67
+ active: true,
68
+ created_at: new Date().toISOString()
69
+ },
70
+ {
71
+ id: 'prod_local_prototype',
72
+ price_id: 'price_local_prototype',
73
+ name: 'Prototype Tier',
74
+ description: 'Clickable prototype with core user flows',
75
+ price: 250000,
76
+ currency: 'usd',
77
+ features: [
78
+ 'Clickable interface',
79
+ 'Core user flows',
80
+ 'Demo environment',
81
+ 'Shareable prototype link'
82
+ ],
83
+ active: true,
84
+ created_at: new Date().toISOString()
85
+ },
86
+ {
87
+ id: 'prod_local_strategy',
88
+ price_id: 'price_local_strategy',
89
+ name: 'Strategy Tier',
90
+ description: 'Technical architecture and implementation roadmap',
91
+ price: 1000000,
92
+ currency: 'usd',
93
+ features: [
94
+ 'Technical architecture design',
95
+ 'Development roadmap',
96
+ 'Tech stack decisions',
97
+ 'Implementation timeline'
98
+ ],
99
+ active: true,
100
+ created_at: new Date().toISOString()
101
+ },
102
+ {
103
+ id: 'prod_local_build',
104
+ price_id: 'price_local_build',
105
+ name: 'Build Tier',
106
+ description: 'Full application development and deployment',
107
+ price: 2500000,
108
+ currency: 'usd',
109
+ features: [
110
+ 'Full application development',
111
+ 'Database & backend',
112
+ 'Production deployment',
113
+ 'Technical handoff'
114
+ ],
115
+ active: true,
116
+ created_at: new Date().toISOString()
117
+ }
118
+ ];
119
+ }
120
+
121
+ // Product Management
122
+
123
+ saveProducts() {
124
+ fs.writeFileSync(this.productsFile, JSON.stringify(this.products, null, 2));
125
+ }
126
+
127
+ listProducts(filter = {}) {
128
+ let filtered = [...this.products];
129
+
130
+ if (filter.active !== undefined) {
131
+ filtered = filtered.filter(p => p.active === filter.active);
132
+ }
133
+
134
+ return filtered;
135
+ }
136
+
137
+ getProduct(id) {
138
+ return this.products.find(p => p.id === id || p.price_id === id);
139
+ }
140
+
141
+ createProduct(data) {
142
+ const product = {
143
+ id: `prod_local_${uuidv4().substring(0, 8)}`,
144
+ price_id: `price_local_${uuidv4().substring(0, 8)}`,
145
+ name: data.name,
146
+ description: data.description,
147
+ price: data.price,
148
+ currency: data.currency || 'usd',
149
+ features: data.features || [],
150
+ active: true,
151
+ created_at: new Date().toISOString(),
152
+ updated_at: new Date().toISOString()
153
+ };
154
+
155
+ this.products.push(product);
156
+ this.saveProducts();
157
+
158
+ return product;
159
+ }
160
+
161
+ updateProduct(id, updates) {
162
+ const index = this.products.findIndex(p => p.id === id || p.price_id === id);
163
+ if (index === -1) {
164
+ throw new Error('Product not found');
165
+ }
166
+
167
+ this.products[index] = {
168
+ ...this.products[index],
169
+ ...updates,
170
+ updated_at: new Date().toISOString()
171
+ };
172
+
173
+ this.saveProducts();
174
+ return this.products[index];
175
+ }
176
+
177
+ deleteProduct(id) {
178
+ const index = this.products.findIndex(p => p.id === id || p.price_id === id);
179
+ if (index === -1) {
180
+ throw new Error('Product not found');
181
+ }
182
+
183
+ // Soft delete - just mark as inactive
184
+ this.products[index].active = false;
185
+ this.products[index].deleted_at = new Date().toISOString();
186
+
187
+ this.saveProducts();
188
+ return { success: true };
189
+ }
190
+
191
+ // Order Management
192
+
193
+ saveOrders() {
194
+ fs.writeFileSync(this.ordersFile, JSON.stringify(this.orders, null, 2));
195
+ }
196
+
197
+ listOrders(filter = {}) {
198
+ let filtered = [...this.orders];
199
+
200
+ if (filter.status) {
201
+ filtered = filtered.filter(o => o.status === filter.status);
202
+ }
203
+
204
+ if (filter.customer_id) {
205
+ filtered = filtered.filter(o => o.customer_id === filter.customer_id);
206
+ }
207
+
208
+ // Sort by created_at descending
209
+ filtered.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
210
+
211
+ return filtered;
212
+ }
213
+
214
+ createOrder(data) {
215
+ const order = {
216
+ id: `order_local_${uuidv4().substring(0, 8)}`,
217
+ customer_id: data.customer_id || `cus_local_${uuidv4().substring(0, 8)}`,
218
+ customer_email: data.customer_email,
219
+ product_id: data.product_id,
220
+ price_id: data.price_id,
221
+ amount: data.amount,
222
+ currency: data.currency || 'usd',
223
+ status: 'pending',
224
+ payment_intent_id: `pi_local_${uuidv4().substring(0, 8)}`,
225
+ metadata: data.metadata || {},
226
+ created_at: new Date().toISOString(),
227
+ updated_at: new Date().toISOString()
228
+ };
229
+
230
+ this.orders.push(order);
231
+ this.saveOrders();
232
+
233
+ return order;
234
+ }
235
+
236
+ updateOrderStatus(orderId, status) {
237
+ const order = this.orders.find(o => o.id === orderId);
238
+ if (!order) {
239
+ throw new Error('Order not found');
240
+ }
241
+
242
+ order.status = status;
243
+ order.updated_at = new Date().toISOString();
244
+
245
+ if (status === 'completed') {
246
+ order.completed_at = new Date().toISOString();
247
+ } else if (status === 'cancelled') {
248
+ order.cancelled_at = new Date().toISOString();
249
+ }
250
+
251
+ this.saveOrders();
252
+ return order;
253
+ }
254
+
255
+ // Customer Management
256
+
257
+ saveCustomers() {
258
+ fs.writeFileSync(this.customersFile, JSON.stringify(this.customers, null, 2));
259
+ }
260
+
261
+ listCustomers() {
262
+ return this.customers;
263
+ }
264
+
265
+ createCustomer(data) {
266
+ const customer = {
267
+ id: `cus_local_${uuidv4().substring(0, 8)}`,
268
+ email: data.email,
269
+ name: data.name,
270
+ metadata: data.metadata || {},
271
+ created_at: new Date().toISOString(),
272
+ updated_at: new Date().toISOString()
273
+ };
274
+
275
+ this.customers.push(customer);
276
+ this.saveCustomers();
277
+
278
+ return customer;
279
+ }
280
+
281
+ // Analytics
282
+
283
+ getAnalytics() {
284
+ const now = new Date();
285
+ const thirtyDaysAgo = new Date(now - 30 * 24 * 60 * 60 * 1000);
286
+
287
+ const recentOrders = this.orders.filter(o =>
288
+ new Date(o.created_at) > thirtyDaysAgo
289
+ );
290
+
291
+ const completedOrders = recentOrders.filter(o => o.status === 'completed');
292
+ const totalRevenue = completedOrders.reduce((sum, o) => sum + o.amount, 0);
293
+
294
+ return {
295
+ total_orders: this.orders.length,
296
+ recent_orders: recentOrders.length,
297
+ completed_orders: completedOrders.length,
298
+ total_revenue: totalRevenue,
299
+ total_customers: this.customers.length,
300
+ total_products: this.products.filter(p => p.active).length,
301
+ revenue_by_product: this.getRevenueByProduct(),
302
+ orders_by_status: this.getOrdersByStatus(),
303
+ recent_activity: this.getRecentActivity()
304
+ };
305
+ }
306
+
307
+ getRevenueByProduct() {
308
+ const revenue = {};
309
+
310
+ this.orders
311
+ .filter(o => o.status === 'completed')
312
+ .forEach(order => {
313
+ const product = this.getProduct(order.product_id);
314
+ if (product) {
315
+ if (!revenue[product.name]) {
316
+ revenue[product.name] = {
317
+ total: 0,
318
+ count: 0
319
+ };
320
+ }
321
+ revenue[product.name].total += order.amount;
322
+ revenue[product.name].count += 1;
323
+ }
324
+ });
325
+
326
+ return revenue;
327
+ }
328
+
329
+ getOrdersByStatus() {
330
+ const statuses = {};
331
+
332
+ this.orders.forEach(order => {
333
+ if (!statuses[order.status]) {
334
+ statuses[order.status] = 0;
335
+ }
336
+ statuses[order.status]++;
337
+ });
338
+
339
+ return statuses;
340
+ }
341
+
342
+ getRecentActivity() {
343
+ const activities = [];
344
+
345
+ // Add recent orders
346
+ this.orders.slice(-5).forEach(order => {
347
+ activities.push({
348
+ type: 'order',
349
+ message: `New order #${order.id.substring(12)} - $${order.amount / 100}`,
350
+ timestamp: order.created_at
351
+ });
352
+ });
353
+
354
+ // Add recent customers
355
+ this.customers.slice(-3).forEach(customer => {
356
+ activities.push({
357
+ type: 'customer',
358
+ message: `New customer: ${customer.email}`,
359
+ timestamp: customer.created_at
360
+ });
361
+ });
362
+
363
+ // Sort by timestamp
364
+ activities.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
365
+
366
+ return activities.slice(0, 10);
367
+ }
368
+
369
+ // Export/Import for migration to production
370
+
371
+ exportData() {
372
+ return {
373
+ products: this.products,
374
+ orders: this.orders,
375
+ customers: this.customers,
376
+ exported_at: new Date().toISOString(),
377
+ version: '1.0.0'
378
+ };
379
+ }
380
+
381
+ importData(data) {
382
+ if (data.products) {
383
+ this.products = data.products;
384
+ this.saveProducts();
385
+ }
386
+
387
+ if (data.orders) {
388
+ this.orders = data.orders;
389
+ this.saveOrders();
390
+ }
391
+
392
+ if (data.customers) {
393
+ this.customers = data.customers;
394
+ this.saveCustomers();
395
+ }
396
+
397
+ return {
398
+ success: true,
399
+ imported: {
400
+ products: data.products?.length || 0,
401
+ orders: data.orders?.length || 0,
402
+ customers: data.customers?.length || 0
403
+ }
404
+ };
405
+ }
406
+ }
407
+
408
+ module.exports = LocalAdminManager;
package/src/docs-html.js CHANGED
@@ -402,6 +402,14 @@ NEXT_PUBLIC_SPAPS_API_URL=http://localhost:${port}</code></pre>
402
402
  <h3>šŸ”„ Auto-Refresh</h3>
403
403
  <p>Tokens automatically refresh when expired</p>
404
404
  </div>
405
+ <div class="feature-card">
406
+ <h3>šŸ’³ Mock Stripe</h3>
407
+ <p>Checkout & webhooks work instantly - no Stripe account needed!</p>
408
+ </div>
409
+ <div class="feature-card">
410
+ <h3>⚔ Auto Webhooks</h3>
411
+ <p>Webhooks fire automatically 2 seconds after payment actions</p>
412
+ </div>
405
413
  </div>
406
414
  </section>
407
415
 
@@ -460,16 +468,55 @@ await spaps.refresh();</code></pre>
460
468
  <section id="payments">
461
469
  <h2>Payment Integration</h2>
462
470
 
463
- <h3>Stripe Checkout</h3>
464
- <pre><code>// Create checkout session
471
+ <div class="alert" style="background: #e0f2fe; border-color: #0369a1; margin-bottom: 2rem;">
472
+ <strong>šŸŽ‰ New in v0.3.1:</strong> Local Stripe testing with automatic webhook simulation!
473
+ <br>• No Stripe account required for local development
474
+ <br>• Webhooks fire automatically after payments
475
+ <br>• Mock checkout page included
476
+ <br>• Test webhook UI at <code>/api/stripe/webhooks/test</code>
477
+ </div>
478
+
479
+ <h3>Stripe Checkout (Works in Local Mode!)</h3>
480
+ <pre><code>// Create checkout session - works instantly in local mode!
465
481
  const session = await spaps.createCheckoutSession(
466
- 'price_123abc', // Stripe price ID
482
+ 'price_local_validate', // Use local price IDs
467
483
  'http://localhost:3000/success', // Success URL
468
484
  'http://localhost:3000/cancel' // Cancel URL (optional)
469
485
  );
470
486
 
471
- // Redirect to Stripe
472
- window.location.href = session.data.url;</code></pre>
487
+ // In local mode: Returns mock checkout URL
488
+ // In production: Returns real Stripe URL
489
+ window.location.href = session.data.url;
490
+
491
+ // Webhook fires automatically after 2 seconds in local mode!</code></pre>
492
+
493
+ <h3>Local Mode Test Products</h3>
494
+ <pre><code>// Pre-configured test products (Buildooor tiers)
495
+ const prices = {
496
+ 'price_local_validate': '$500 - Validate tier',
497
+ 'price_local_prototype': '$2,500 - Prototype tier',
498
+ 'price_local_strategy': '$10,000 - Strategy tier',
499
+ 'price_local_build': '$25,000 - Build tier'
500
+ };</code></pre>
501
+
502
+ <h3>Webhook Testing</h3>
503
+ <pre><code>// Your webhook handler (same code for local & production!)
504
+ app.post('/webhook', (req, res) => {
505
+ const event = req.body;
506
+
507
+ switch(event.type) {
508
+ case 'checkout.session.completed':
509
+ // In local: fires 2 seconds after checkout
510
+ // In production: fires when payment completes
511
+ console.log('Payment successful!');
512
+ break;
513
+ }
514
+
515
+ res.json({ received: true });
516
+ });
517
+
518
+ // Test webhooks via UI (local only)
519
+ // Visit: http://localhost:${port}/api/stripe/webhooks/test</code></pre>
473
520
 
474
521
  <h3>Subscription Management</h3>
475
522
  <pre><code>// Get current subscription
@@ -9,14 +9,21 @@ const express = require('express');
9
9
  const cors = require('cors');
10
10
  const chalk = require('chalk');
11
11
  const { generateDocsHTML } = require('./docs-html');
12
+ const StripeLocalManager = require('./stripe-local');
13
+ const LocalAdminManager = require('./admin-local');
12
14
 
13
15
  class LocalServer {
14
16
  constructor(options = {}) {
15
- this.port = options.port || process.env.PORT || 3300;
17
+ this.port = options.port || process.env.PORT || 3456;
16
18
  this.json = options.json || false;
17
19
  this.app = express();
20
+ this.stripeManager = null;
21
+ this.adminManager = new LocalAdminManager();
18
22
  this.setupMiddleware();
19
23
  this.setupRoutes();
24
+ this.setupStripeRoutes();
25
+ this.setupAdminRoutes();
26
+ this.setupCatchAll();
20
27
  }
21
28
 
22
29
  setupMiddleware() {
@@ -170,7 +177,449 @@ class LocalServer {
170
177
  this.app.get('/docs', (req, res) => {
171
178
  res.send(generateDocsHTML(this.port));
172
179
  });
180
+ }
181
+
182
+ setupStripeRoutes() {
183
+ // Mock Stripe checkout session
184
+ this.app.post('/api/stripe/create-checkout-session', async (req, res) => {
185
+ const { price_id, success_url, cancel_url } = req.body;
186
+ const sessionId = 'cs_local_' + Date.now();
187
+
188
+ res.json({
189
+ sessionId,
190
+ url: `http://localhost:${this.port}/checkout/${sessionId}?success=${encodeURIComponent(success_url)}&cancel=${encodeURIComponent(cancel_url)}`
191
+ });
192
+
193
+ // Simulate webhook after delay
194
+ setTimeout(async () => {
195
+ try {
196
+ await this.simulateCheckoutWebhook(sessionId, price_id);
197
+ if (!this.json) {
198
+ console.log(chalk.blue(`⚔ Webhook simulated: checkout.session.completed`));
199
+ }
200
+ } catch (error) {
201
+ console.error(chalk.red('Webhook simulation failed:'), error);
202
+ }
203
+ }, 2000);
204
+ });
205
+
206
+ // Mock checkout page
207
+ this.app.get('/checkout/:sessionId', (req, res) => {
208
+ const { sessionId } = req.params;
209
+ const { success, cancel } = req.query;
210
+
211
+ res.send(`
212
+ <!DOCTYPE html>
213
+ <html>
214
+ <head>
215
+ <title>SPAPS Local - Mock Checkout</title>
216
+ <style>
217
+ body { font-family: system-ui; max-width: 400px; margin: 100px auto; padding: 2rem; }
218
+ button { width: 100%; padding: 1rem; margin: 0.5rem 0; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; }
219
+ .pay { background: #635bff; color: white; }
220
+ .pay:hover { background: #4b41e0; }
221
+ .cancel { background: #f5f5f5; }
222
+ .cancel:hover { background: #e5e5e5; }
223
+ </style>
224
+ </head>
225
+ <body>
226
+ <h1>šŸ  Mock Checkout</h1>
227
+ <p>Session: ${sessionId}</p>
228
+ <p>This is a mock checkout page for local development.</p>
229
+
230
+ <button class="pay" onclick="window.location='${success}'">
231
+ šŸ’³ Complete Payment
232
+ </button>
233
+
234
+ <button class="cancel" onclick="window.location='${cancel}'">
235
+ Cancel
236
+ </button>
237
+
238
+ <p style="margin-top: 2rem; color: #666; font-size: 14px;">
239
+ In production, this would be a real Stripe Checkout page.
240
+ </p>
241
+ </body>
242
+ </html>
243
+ `);
244
+ });
245
+
246
+ // Mock webhook endpoint
247
+ this.app.post('/api/stripe/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
248
+ // In local mode, accept all webhooks
249
+ const event = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
250
+
251
+ if (!this.json) {
252
+ console.log(chalk.blue(`⚔ Webhook received: ${event.type}`));
253
+ }
254
+
255
+ // Store for testing
256
+ this.lastWebhookEvent = event;
257
+
258
+ res.json({ received: true });
259
+ });
260
+
261
+ // Webhook testing UI
262
+ this.app.get('/api/stripe/webhooks/test', (req, res) => {
263
+ res.send(`
264
+ <!DOCTYPE html>
265
+ <html>
266
+ <head>
267
+ <title>SPAPS - Stripe Webhook Tester</title>
268
+ <style>
269
+ body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 2rem; }
270
+ button { background: #635bff; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 6px; cursor: pointer; margin: 0.25rem; }
271
+ button:hover { background: #4b41e0; }
272
+ .event { background: #f9f9f9; padding: 1rem; margin: 1rem 0; border-radius: 8px; border-left: 4px solid #635bff; }
273
+ </style>
274
+ </head>
275
+ <body>
276
+ <h1>šŸ  Stripe Webhook Tester</h1>
277
+
278
+ <h2>Simulate Events</h2>
279
+ <div>
280
+ <button onclick="simulate('checkout.session.completed')">Checkout Completed</button>
281
+ <button onclick="simulate('payment_intent.succeeded')">Payment Success</button>
282
+ <button onclick="simulate('customer.subscription.created')">Subscription Created</button>
283
+ </div>
284
+
285
+ <h2>Last Event</h2>
286
+ <div id="lastEvent">No events yet</div>
287
+
288
+ <script>
289
+ async function simulate(type) {
290
+ const response = await fetch('/api/stripe/webhooks', {
291
+ method: 'POST',
292
+ headers: { 'Content-Type': 'application/json' },
293
+ body: JSON.stringify({
294
+ id: 'evt_local_' + Date.now(),
295
+ type: type,
296
+ data: { object: { id: type.split('.')[0] + '_' + Date.now() } }
297
+ })
298
+ });
299
+
300
+ if (response.ok) {
301
+ document.getElementById('lastEvent').innerHTML =
302
+ '<div class="event">āœ… ' + type + ' - ' + new Date().toLocaleTimeString() + '</div>';
303
+ }
304
+ }
305
+ </script>
306
+ </body>
307
+ </html>
308
+ `);
309
+ });
310
+ }
311
+
312
+ async simulateCheckoutWebhook(sessionId, priceId) {
313
+ const event = {
314
+ id: 'evt_local_' + Date.now(),
315
+ type: 'checkout.session.completed',
316
+ data: {
317
+ object: {
318
+ id: sessionId,
319
+ amount_total: this.getPriceAmount(priceId),
320
+ currency: 'usd',
321
+ customer: 'cus_local_' + Date.now(),
322
+ payment_status: 'paid',
323
+ status: 'complete',
324
+ metadata: { app_id: 'local-app-001', price_id: priceId }
325
+ }
326
+ }
327
+ };
328
+
329
+ // Send to webhook endpoint
330
+ const response = await fetch(`http://localhost:${this.port}/api/stripe/webhooks`, {
331
+ method: 'POST',
332
+ headers: { 'Content-Type': 'application/json' },
333
+ body: JSON.stringify(event)
334
+ });
335
+
336
+ return response.ok;
337
+ }
338
+
339
+ getPriceAmount(priceId) {
340
+ const prices = {
341
+ 'price_local_validate': 50000,
342
+ 'price_local_prototype': 250000,
343
+ 'price_local_strategy': 1000000,
344
+ 'price_local_build': 2500000
345
+ };
346
+ return prices[priceId] || 10000;
347
+ }
348
+
349
+ setupAdminRoutes() {
350
+ // Admin API endpoints
351
+
352
+ // List products
353
+ this.app.get('/api/admin/products', (req, res) => {
354
+ const products = this.adminManager.listProducts();
355
+ res.json({ success: true, data: products });
356
+ });
357
+
358
+ // Get single product
359
+ this.app.get('/api/admin/products/:id', (req, res) => {
360
+ try {
361
+ const product = this.adminManager.getProduct(req.params.id);
362
+ if (!product) {
363
+ return res.status(404).json({ success: false, error: 'Product not found' });
364
+ }
365
+ res.json({ success: true, data: product });
366
+ } catch (error) {
367
+ res.status(500).json({ success: false, error: error.message });
368
+ }
369
+ });
370
+
371
+ // Create product
372
+ this.app.post('/api/admin/products', (req, res) => {
373
+ try {
374
+ const product = this.adminManager.createProduct(req.body);
375
+ res.json({ success: true, data: product });
376
+ } catch (error) {
377
+ res.status(400).json({ success: false, error: error.message });
378
+ }
379
+ });
380
+
381
+ // Update product
382
+ this.app.put('/api/admin/products/:id', (req, res) => {
383
+ try {
384
+ const product = this.adminManager.updateProduct(req.params.id, req.body);
385
+ res.json({ success: true, data: product });
386
+ } catch (error) {
387
+ res.status(400).json({ success: false, error: error.message });
388
+ }
389
+ });
390
+
391
+ // Delete product
392
+ this.app.delete('/api/admin/products/:id', (req, res) => {
393
+ try {
394
+ const result = this.adminManager.deleteProduct(req.params.id);
395
+ res.json(result);
396
+ } catch (error) {
397
+ res.status(400).json({ success: false, error: error.message });
398
+ }
399
+ });
400
+
401
+ // List orders
402
+ this.app.get('/api/admin/orders', (req, res) => {
403
+ const orders = this.adminManager.listOrders(req.query);
404
+ res.json({ success: true, data: orders });
405
+ });
406
+
407
+ // Create order (for testing)
408
+ this.app.post('/api/admin/orders', (req, res) => {
409
+ try {
410
+ const order = this.adminManager.createOrder(req.body);
411
+ res.json({ success: true, data: order });
412
+ } catch (error) {
413
+ res.status(400).json({ success: false, error: error.message });
414
+ }
415
+ });
416
+
417
+ // Update order status
418
+ this.app.patch('/api/admin/orders/:id/status', (req, res) => {
419
+ try {
420
+ const order = this.adminManager.updateOrderStatus(req.params.id, req.body.status);
421
+ res.json({ success: true, data: order });
422
+ } catch (error) {
423
+ res.status(400).json({ success: false, error: error.message });
424
+ }
425
+ });
426
+
427
+ // Analytics dashboard
428
+ this.app.get('/api/admin/analytics', (req, res) => {
429
+ const analytics = this.adminManager.getAnalytics();
430
+ res.json({ success: true, data: analytics });
431
+ });
432
+
433
+ // Export data (for migration to production)
434
+ this.app.get('/api/admin/export', (req, res) => {
435
+ const data = this.adminManager.exportData();
436
+ res.json({ success: true, data });
437
+ });
438
+
439
+ // Import data
440
+ this.app.post('/api/admin/import', (req, res) => {
441
+ try {
442
+ const result = this.adminManager.importData(req.body);
443
+ res.json({ success: true, data: result });
444
+ } catch (error) {
445
+ res.status(400).json({ success: false, error: error.message });
446
+ }
447
+ });
448
+
449
+ // Admin UI Dashboard
450
+ this.app.get('/admin', (req, res) => {
451
+ const analytics = this.adminManager.getAnalytics();
452
+ const products = this.adminManager.listProducts();
453
+
454
+ res.send(`
455
+ <!DOCTYPE html>
456
+ <html>
457
+ <head>
458
+ <title>SPAPS Admin - Local Mode</title>
459
+ <style>
460
+ * { margin: 0; padding: 0; box-sizing: border-box; }
461
+ body { font-family: system-ui; background: #f5f5f5; }
462
+ .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 2rem; }
463
+ .container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
464
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; margin: 2rem 0; }
465
+ .card { background: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
466
+ .card h3 { margin-bottom: 1rem; color: #333; }
467
+ .stat { font-size: 2rem; font-weight: bold; color: #667eea; }
468
+ .label { color: #666; font-size: 0.9rem; }
469
+ table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
470
+ th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #eee; }
471
+ th { background: #f9f9f9; font-weight: 600; }
472
+ .btn { background: #667eea; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; }
473
+ .btn:hover { background: #764ba2; }
474
+ .badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.85rem; }
475
+ .badge-success { background: #10b981; color: white; }
476
+ .badge-pending { background: #f59e0b; color: white; }
477
+ .nav { display: flex; gap: 2rem; margin-bottom: 2rem; }
478
+ .nav a { color: #667eea; text-decoration: none; font-weight: 500; }
479
+ .nav a:hover { text-decoration: underline; }
480
+ </style>
481
+ </head>
482
+ <body>
483
+ <div class="header">
484
+ <div class="container">
485
+ <h1>šŸ  SPAPS Admin Dashboard</h1>
486
+ <p>Local Development Mode - Managing test data</p>
487
+ </div>
488
+ </div>
489
+
490
+ <div class="container">
491
+ <div class="nav">
492
+ <a href="/admin">Dashboard</a>
493
+ <a href="/api/admin/products">Products API</a>
494
+ <a href="/api/admin/orders">Orders API</a>
495
+ <a href="/api/admin/export">Export Data</a>
496
+ <a href="/docs">Documentation</a>
497
+ </div>
498
+
499
+ <div class="grid">
500
+ <div class="card">
501
+ <h3>Total Revenue</h3>
502
+ <div class="stat">$${(analytics.total_revenue / 100).toLocaleString()}</div>
503
+ <div class="label">From ${analytics.completed_orders} orders</div>
504
+ </div>
505
+
506
+ <div class="card">
507
+ <h3>Active Products</h3>
508
+ <div class="stat">${analytics.total_products}</div>
509
+ <div class="label">Available for purchase</div>
510
+ </div>
511
+
512
+ <div class="card">
513
+ <h3>Customers</h3>
514
+ <div class="stat">${analytics.total_customers}</div>
515
+ <div class="label">Total registered</div>
516
+ </div>
517
+
518
+ <div class="card">
519
+ <h3>Recent Orders</h3>
520
+ <div class="stat">${analytics.recent_orders}</div>
521
+ <div class="label">Last 30 days</div>
522
+ </div>
523
+ </div>
524
+
525
+ <div class="card">
526
+ <h3>Products</h3>
527
+ <table>
528
+ <thead>
529
+ <tr>
530
+ <th>Name</th>
531
+ <th>Price</th>
532
+ <th>Status</th>
533
+ <th>Actions</th>
534
+ </tr>
535
+ </thead>
536
+ <tbody>
537
+ ${products.map(p => `
538
+ <tr>
539
+ <td><strong>${p.name}</strong><br><small>${p.description}</small></td>
540
+ <td>$${(p.price / 100).toLocaleString()}</td>
541
+ <td><span class="badge badge-${p.active ? 'success' : 'pending'}">${p.active ? 'Active' : 'Inactive'}</span></td>
542
+ <td>
543
+ <button class="btn" onclick="editProduct('${p.id}')">Edit</button>
544
+ </td>
545
+ </tr>
546
+ `).join('')}
547
+ </tbody>
548
+ </table>
549
+
550
+ <div style="margin-top: 1rem;">
551
+ <button class="btn" onclick="addProduct()">+ Add Product</button>
552
+ </div>
553
+ </div>
554
+
555
+ <div class="card" style="margin-top: 2rem;">
556
+ <h3>Recent Activity</h3>
557
+ <table>
558
+ <tbody>
559
+ ${analytics.recent_activity.map(a => `
560
+ <tr>
561
+ <td>${a.message}</td>
562
+ <td style="text-align: right; color: #999;">${new Date(a.timestamp).toLocaleString()}</td>
563
+ </tr>
564
+ `).join('')}
565
+ </tbody>
566
+ </table>
567
+ </div>
568
+
569
+ <div class="card" style="margin-top: 2rem; background: #fef3c7;">
570
+ <h3>šŸ’” Local Mode Notice</h3>
571
+ <p style="margin-top: 1rem;">
572
+ You're running in local development mode. All data is stored locally in <code>.spaps/</code> directory.
573
+ </p>
574
+ <p style="margin-top: 0.5rem;">
575
+ To migrate to production:
576
+ </p>
577
+ <ol style="margin: 1rem 0 0 2rem;">
578
+ <li>Export your data: <code>GET /api/admin/export</code></li>
579
+ <li>Set up Stripe products in production</li>
580
+ <li>Import customer data to production database</li>
581
+ <li>Update environment variables</li>
582
+ </ol>
583
+ </div>
584
+ </div>
585
+
586
+ <script>
587
+ function editProduct(id) {
588
+ window.location.href = '/api/admin/products/' + id;
589
+ }
590
+
591
+ function addProduct() {
592
+ const name = prompt('Product name:');
593
+ if (!name) return;
594
+
595
+ const price = prompt('Price (in dollars):');
596
+ if (!price) return;
597
+
598
+ fetch('/api/admin/products', {
599
+ method: 'POST',
600
+ headers: { 'Content-Type': 'application/json' },
601
+ body: JSON.stringify({
602
+ name,
603
+ description: prompt('Description:') || '',
604
+ price: Math.round(parseFloat(price) * 100)
605
+ })
606
+ })
607
+ .then(r => r.json())
608
+ .then(data => {
609
+ if (data.success) {
610
+ alert('Product created!');
611
+ location.reload();
612
+ }
613
+ });
614
+ }
615
+ </script>
616
+ </body>
617
+ </html>
618
+ `);
619
+ });
620
+ }
173
621
 
622
+ setupCatchAll() {
174
623
  // Catch-all for unimplemented routes
175
624
  this.app.use((req, res) => {
176
625
  res.status(404).json({
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Local Stripe Integration for SPAPS CLI
3
+ * Handles webhook forwarding and testing without Stripe CLI
4
+ */
5
+
6
+ const { spawn } = require('child_process');
7
+ const chalk = require('chalk');
8
+ const ora = require('ora');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ class StripeLocalManager {
13
+ constructor(options = {}) {
14
+ this.port = options.port || 3300;
15
+ this.stripeCliProcess = null;
16
+ this.useBuiltInSimulator = options.simulator !== false;
17
+ this.webhookSecret = 'whsec_local_development_secret';
18
+ }
19
+
20
+ /**
21
+ * Check if Stripe CLI is installed
22
+ */
23
+ async checkStripeCLI() {
24
+ try {
25
+ const { execSync } = require('child_process');
26
+ execSync('stripe --version', { stdio: 'ignore' });
27
+ return true;
28
+ } catch (error) {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Start Stripe webhook forwarding
35
+ */
36
+ async startWebhookForwarding() {
37
+ const hasStripeCLI = await this.checkStripeCLI();
38
+
39
+ if (hasStripeCLI && !this.useBuiltInSimulator) {
40
+ return this.startStripeCLI();
41
+ } else {
42
+ return this.startBuiltInSimulator();
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Start Stripe CLI webhook forwarding
48
+ */
49
+ async startStripeCLI() {
50
+ console.log(chalk.blue('\nšŸ“” Starting Stripe CLI webhook forwarding...'));
51
+
52
+ return new Promise((resolve, reject) => {
53
+ this.stripeCliProcess = spawn('stripe', [
54
+ 'listen',
55
+ '--forward-to',
56
+ `localhost:${this.port}/api/stripe/webhooks`,
57
+ '--print-json'
58
+ ]);
59
+
60
+ let webhookSecret = null;
61
+
62
+ this.stripeCliProcess.stdout.on('data', (data) => {
63
+ const output = data.toString();
64
+
65
+ // Parse webhook secret from output
66
+ if (!webhookSecret && output.includes('whsec_')) {
67
+ const match = output.match(/whsec_[a-zA-Z0-9]+/);
68
+ if (match) {
69
+ webhookSecret = match[0];
70
+ console.log(chalk.green(`āœ… Stripe webhooks connected!`));
71
+ console.log(chalk.gray(` Secret: ${webhookSecret}`));
72
+ console.log(chalk.gray(` Forwarding to: http://localhost:${this.port}/api/stripe/webhooks`));
73
+
74
+ // Save webhook secret to env file
75
+ this.saveWebhookSecret(webhookSecret);
76
+
77
+ resolve({
78
+ type: 'stripe-cli',
79
+ secret: webhookSecret,
80
+ url: `http://localhost:${this.port}/api/stripe/webhooks`
81
+ });
82
+ }
83
+ }
84
+
85
+ // Log webhook events
86
+ try {
87
+ const json = JSON.parse(output);
88
+ if (json.type) {
89
+ console.log(chalk.blue(`⚔ Webhook: ${json.type}`));
90
+ }
91
+ } catch (e) {
92
+ // Not JSON, ignore
93
+ }
94
+ });
95
+
96
+ this.stripeCliProcess.stderr.on('data', (data) => {
97
+ const error = data.toString();
98
+ if (error.includes('login')) {
99
+ console.log(chalk.yellow('\nāš ļø Stripe CLI not logged in'));
100
+ console.log(chalk.cyan(' Run: stripe login'));
101
+ reject(new Error('Stripe CLI not authenticated'));
102
+ } else if (error.includes('Error')) {
103
+ console.error(chalk.red(`Stripe CLI error: ${error}`));
104
+ }
105
+ });
106
+
107
+ this.stripeCliProcess.on('close', (code) => {
108
+ if (code !== 0 && code !== null) {
109
+ reject(new Error(`Stripe CLI exited with code ${code}`));
110
+ }
111
+ });
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Start built-in webhook simulator
117
+ */
118
+ async startBuiltInSimulator() {
119
+ console.log(chalk.blue('\nšŸŽ­ Starting built-in webhook simulator...'));
120
+ console.log(chalk.gray(' (Stripe CLI not found or simulator mode enabled)'));
121
+
122
+ // The local server will handle webhook simulation
123
+ console.log(chalk.green(`āœ… Webhook simulator ready!`));
124
+ console.log(chalk.gray(` Test UI: http://localhost:${this.port}/api/stripe/webhooks/test`));
125
+ console.log(chalk.gray(` Endpoint: http://localhost:${this.port}/api/stripe/webhooks`));
126
+
127
+ return {
128
+ type: 'simulator',
129
+ secret: this.webhookSecret,
130
+ url: `http://localhost:${this.port}/api/stripe/webhooks`,
131
+ testUI: `http://localhost:${this.port}/api/stripe/webhooks/test`
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Save webhook secret to local env file
137
+ */
138
+ saveWebhookSecret(secret) {
139
+ const envPath = path.join(process.cwd(), '.env.local');
140
+
141
+ try {
142
+ let envContent = '';
143
+ if (fs.existsSync(envPath)) {
144
+ envContent = fs.readFileSync(envPath, 'utf8');
145
+ }
146
+
147
+ // Update or add webhook secret
148
+ if (envContent.includes('STRIPE_WEBHOOK_SECRET=')) {
149
+ envContent = envContent.replace(
150
+ /STRIPE_WEBHOOK_SECRET=.*/,
151
+ `STRIPE_WEBHOOK_SECRET=${secret}`
152
+ );
153
+ } else {
154
+ envContent += `\n# Auto-generated by SPAPS\nSTRIPE_WEBHOOK_SECRET=${secret}\n`;
155
+ }
156
+
157
+ fs.writeFileSync(envPath, envContent);
158
+ console.log(chalk.gray(` Secret saved to .env.local`));
159
+ } catch (error) {
160
+ console.error(chalk.yellow(` Could not save webhook secret: ${error.message}`));
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Stop webhook forwarding
166
+ */
167
+ stop() {
168
+ if (this.stripeCliProcess) {
169
+ console.log(chalk.yellow('\nšŸ‘‹ Stopping Stripe webhook forwarding...'));
170
+ this.stripeCliProcess.kill();
171
+ this.stripeCliProcess = null;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Create test products for local development
177
+ */
178
+ async createTestProducts() {
179
+ console.log(chalk.blue('\nšŸ“¦ Creating test Stripe products...'));
180
+
181
+ const products = [
182
+ {
183
+ id: 'prod_local_validate',
184
+ name: 'Validate Tier',
185
+ description: 'Landing page with data capture',
186
+ price: 50000, // $500
187
+ price_id: 'price_local_validate'
188
+ },
189
+ {
190
+ id: 'prod_local_prototype',
191
+ name: 'Prototype Tier',
192
+ description: 'Clickable prototype with core flows',
193
+ price: 250000, // $2,500
194
+ price_id: 'price_local_prototype'
195
+ },
196
+ {
197
+ id: 'prod_local_strategy',
198
+ name: 'Strategy Tier',
199
+ description: 'Technical architecture and roadmap',
200
+ price: 1000000, // $10,000
201
+ price_id: 'price_local_strategy'
202
+ },
203
+ {
204
+ id: 'prod_local_build',
205
+ name: 'Build Tier',
206
+ description: 'Full application development',
207
+ price: 2500000, // $25,000
208
+ price_id: 'price_local_build'
209
+ }
210
+ ];
211
+
212
+ // Store products in local config
213
+ const configPath = path.join(process.cwd(), '.spaps', 'stripe-products.json');
214
+ const configDir = path.dirname(configPath);
215
+
216
+ if (!fs.existsSync(configDir)) {
217
+ fs.mkdirSync(configDir, { recursive: true });
218
+ }
219
+
220
+ fs.writeFileSync(configPath, JSON.stringify(products, null, 2));
221
+
222
+ console.log(chalk.green('āœ… Test products created:'));
223
+ products.forEach(p => {
224
+ console.log(chalk.gray(` - ${p.name}: $${p.price / 100}`));
225
+ });
226
+
227
+ return products;
228
+ }
229
+
230
+ /**
231
+ * Show webhook testing instructions
232
+ */
233
+ showInstructions() {
234
+ console.log(chalk.yellow('\nšŸ“š Webhook Testing Guide:'));
235
+ console.log();
236
+
237
+ if (this.useBuiltInSimulator) {
238
+ console.log('1. Open webhook tester UI:');
239
+ console.log(chalk.cyan(` http://localhost:${this.port}/api/stripe/webhooks/test`));
240
+ console.log();
241
+ console.log('2. Or trigger via code:');
242
+ console.log(chalk.gray(' ```javascript'));
243
+ console.log(chalk.gray(' // Your app code'));
244
+ console.log(chalk.gray(' const result = await spaps.createCheckoutSession(...);'));
245
+ console.log(chalk.gray(' // Webhook fires automatically after 1 second'));
246
+ console.log(chalk.gray(' ```'));
247
+ } else {
248
+ console.log('1. Trigger test events:');
249
+ console.log(chalk.cyan(' stripe trigger payment_intent.succeeded'));
250
+ console.log();
251
+ console.log('2. Or use the Stripe Dashboard:');
252
+ console.log(chalk.cyan(' https://dashboard.stripe.com/test/webhooks'));
253
+ }
254
+
255
+ console.log();
256
+ console.log(chalk.blue('šŸ’” Tips:'));
257
+ console.log(' - Webhooks auto-retry on failure');
258
+ console.log(' - Check logs for webhook events');
259
+ console.log(' - Use webhook secret in your app');
260
+ }
261
+ }
262
+
263
+ module.exports = StripeLocalManager;