UNPKG

spaps

Version:

Sweet Potato Authentication & Payment Service CLI - Zero-config local development with built-in admin middleware and permission utilities

1,492 lines (1,357 loc) 57.6 kB
#!/usr/bin/env node /** * SPAPS Local Development Server * Minimal, zero-config server for local development */ const express = require('express'); const cors = require('cors'); const chalk = require('chalk'); const fs = require('fs'); const path = require('path'); const { generateDocsHTML } = require('./docs-html'); let swaggerUiDist = null; try { swaggerUiDist = require('swagger-ui-dist'); } catch {} const StripeLocalManager = require('./stripe-local'); const LocalAdminManager = require('./admin-local'); // Stripe configuration for test mode const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY || 'sk_test_51S1WOy2HT0E1dOewiHvzt7T96PDwjocSDDUuc2ur569AVA5fDj4UpNM66lujrda1tTYrgooG0Z1dNFZfwEZuZdcA00nuVLJW67'); const STRIPE_PUBLISHABLE_KEY = process.env.STRIPE_PUBLISHABLE_KEY || 'pk_test_51S1WOy2HT0E1dOewb2EkxZIaPkz7v3zMM9VxuBoxgNILYMmS85I4zrAWTkevyUQcaWlWUoC2NYnB8X5ZKd5e7Ifc005IzIW6H2'; class LocalServer { constructor(options = {}) { this.port = options.port || process.env.PORT || 3456; this.json = options.json || false; this.stripeMode = options.stripeMode || (process.env.USE_REAL_STRIPE === 'false' ? 'mock' : 'real'); this.seedMode = options.seedMode || 'none'; this.app = express(); this.stripeManager = null; this.adminManager = new LocalAdminManager(); this.setupMiddleware(); this.setupRoutes(); this.setupStripeRoutes(); this.setupAdminRoutes(); this.setupCatchAll(); // Optional demo seeding (idempotent) if (this.seedMode === 'demo') { try { this.seedDemoData(); } catch (e) { if (!this.json) console.warn(chalk.yellow(`⚠️ Seed failed: ${e.message}`)); } } } setupMiddleware() { // CORS - allow everything in local mode this.app.use(cors({ origin: true, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key', 'X-Test-User', 'x-local-mode', 'X-Local-Mode'], })); // Body parsing this.app.use(express.json()); this.app.use(express.urlencoded({ extended: true })); // Serve Swagger UI assets locally if available if (swaggerUiDist && typeof swaggerUiDist.getAbsoluteFSPath === 'function') { const uiPath = swaggerUiDist.getAbsoluteFSPath(); this.app.use('/swagger-ui', express.static(uiPath)); } // Local mode indicator this.app.use((req, res, next) => { res.setHeader('X-SPAPS-Mode', 'local-development'); // Auto-auth in local mode if (!req.headers.authorization && !req.headers['x-api-key']) { req.headers['x-api-key'] = 'local-dev-key'; req.user = { id: 'local-user-123', email: 'dev@localhost', role: req.query._user || req.headers['x-test-user'] || 'user' }; } // Log requests (unless in JSON mode) if (!this.json) { console.log(chalk.dim(`${req.method} ${req.path}`)); } next(); }); } setupRoutes() { // OpenAPI JSON - try to serve repo spec; fallback to manifest; else minimal stub this.app.get('/openapi.json', async (_req, res) => { try { const spec = await this.loadOpenApiSpec(); return res.json(spec); } catch (e) { return res.status(500).json({ error: 'Failed to generate OpenAPI', message: e.message }); } }); // Health check this.app.get('/health', (req, res) => { res.json({ status: 'healthy', mode: 'local-development', version: '0.2.0', timestamp: new Date().toISOString() }); }); // Local mode status this.app.get('/health/local-mode', (req, res) => { res.json({ enabled: true, environment: 'local-development', features: { autoAuth: true, corsEnabled: true, testUsers: ['user', 'admin', 'premium'], apiKeyRequired: false } }); }); // Mock authentication endpoints this.app.post('/api/auth/login', (req, res) => { const { email, password } = req.body; res.json({ success: true, data: { access_token: 'local-jwt-token-' + Date.now(), refresh_token: 'local-refresh-token-' + Date.now(), user: { id: 'local-user-123', email: email || 'dev@localhost', role: 'user' } } }); }); this.app.post('/api/auth/register', (req, res) => { const { email, password } = req.body; res.json({ success: true, data: { access_token: 'local-jwt-token-' + Date.now(), refresh_token: 'local-refresh-token-' + Date.now(), user: { id: 'local-user-' + Date.now(), email: email || 'dev@localhost', role: 'user' } } }); }); this.app.post('/api/auth/wallet-sign-in', (req, res) => { const { wallet_address, chain_type } = req.body; res.json({ success: true, data: { access_token: 'local-jwt-token-' + Date.now(), refresh_token: 'local-refresh-token-' + Date.now(), user: { id: 'local-wallet-user-123', wallet_address, chain_type, role: 'user' } } }); }); this.app.post('/api/auth/refresh', (req, res) => { res.json({ success: true, data: { access_token: 'local-jwt-token-refreshed-' + Date.now(), refresh_token: 'local-refresh-token-refreshed-' + Date.now() } }); }); this.app.post('/api/auth/logout', (req, res) => { res.json({ success: true, message: 'Logged out successfully' }); }); this.app.get('/api/auth/user', (req, res) => { res.json({ id: req.user?.id || 'local-user-123', email: req.user?.email || 'dev@localhost', role: req.user?.role || 'user', created_at: new Date().toISOString() }); }); // Stripe checkout sessions endpoint - REAL or MOCK based on config this.app.post('/api/stripe/checkout-sessions', async (req, res) => { try { if (this.stripeMode === 'real') { // Real Stripe checkout session const { product_name, amount, currency = 'usd', success_url, cancel_url, price_id } = req.body; let lineItems; if (price_id) { // Use existing price lineItems = [{ price: price_id, quantity: 1 }]; } else { // Create price on the fly lineItems = [{ price_data: { currency, product_data: { name: product_name || 'Product' }, unit_amount: amount || 999 }, quantity: 1 }]; } const session = await stripe.checkout.sessions.create({ mode: 'payment', line_items: lineItems, success_url, cancel_url, automatic_tax: { enabled: false }, customer_creation: 'always' }); res.json({ success: true, data: { sessionId: session.id, url: session.url, amount_total: session.amount_total, currency: session.currency, payment_status: session.payment_status, status: session.status } }); } else { // Mock response (fallback) const sessionId = 'cs_local_' + Date.now(); res.json({ success: true, data: { sessionId, url: `http://localhost:${this.port}/checkout/${sessionId}?success=${encodeURIComponent(req.body.success_url)}&cancel=${encodeURIComponent(req.body.cancel_url)}`, amount_total: req.body.amount || 999, currency: req.body.currency || 'usd', payment_status: 'unpaid', status: 'open' } }); } } catch (error) { console.error('Stripe checkout error:', error); res.status(500).json({ success: false, error: { code: 'CHECKOUT_ERROR', message: error.message || 'Failed to create checkout session' } }); } }); // Mock Stripe endpoints (legacy) this.app.post('/api/stripe/create-checkout-session', (req, res) => { res.json({ sessionId: 'cs_test_local_' + Date.now(), url: 'https://checkout.stripe.com/pay/cs_test_local' }); }); this.app.get('/api/stripe/subscription', (req, res) => { res.json({ id: 'sub_local_123', status: 'active', plan: 'premium', current_period_end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() }); }); // Stripe products endpoint - REAL or MOCK based on config this.app.get('/api/stripe/products', async (req, res) => { try { if (this.stripeMode === 'real') { // Fetch real Stripe products const products = await stripe.products.list({ active: req.query.active !== undefined ? req.query.active === 'true' : undefined, limit: req.query.limit ? parseInt(req.query.limit) : 10 }); // Get prices for each product and filter out local-only products const productsWithPrices = await Promise.all( products.data .filter(product => { // Only show products that don't start with prod_local_ (which are local-only placeholders) // These are the real Stripe products created from sync return !product.id.startsWith('prod_local_'); }) .map(async (product) => { const prices = await stripe.prices.list({ product: product.id, active: true, limit: 1 }); const defaultPrice = prices.data[0]; return { id: product.id, name: product.name, description: product.description, price: defaultPrice ? defaultPrice.unit_amount : 0, currency: defaultPrice ? defaultPrice.currency : 'usd', price_id: defaultPrice ? defaultPrice.id : null, active: product.active, metadata: product.metadata }; }) ); res.json({ success: true, data: productsWithPrices }); } else { // Mock response (fallback) res.json({ success: true, data: [ { id: 'prod_local_validate', name: 'Validate', description: 'Proof of concept validation', price: 500, currency: 'usd', active: true }, { id: 'prod_local_prototype', name: 'Prototype', description: 'Build an MVP prototype', price: 2500, currency: 'usd', active: true } ] }); } } catch (error) { console.error('Stripe products error:', error); res.status(500).json({ success: false, error: { code: 'PRODUCTS_ERROR', message: error.message || 'Failed to fetch products' } }); } }); // Mock auth nonce endpoint this.app.post('/api/auth/nonce', (req, res) => { const { wallet_address } = req.body; res.json({ success: true, data: { nonce: 'local-nonce-' + Date.now(), message: `Sign this message to authenticate your wallet ${wallet_address}.\n\nNonce: local-nonce-${Date.now()}`, wallet_address, expires_at: new Date(Date.now() + 300000).toISOString() } }); }); // Mock magic link endpoint this.app.post('/api/auth/magic-link', (req, res) => { const { email } = req.body; res.json({ success: true, message: 'Magic link sent successfully (simulated in local mode)', data: { email, sent_at: new Date().toISOString() } }); }); // Mock customer portal endpoint this.app.post('/api/stripe/customer-portal', (req, res) => { res.json({ success: true, data: { url: `http://localhost:${this.port}/customer-portal?return=${encodeURIComponent(req.body.return_url || 'http://localhost:3000')}` } }); }); // Admin whitelist check endpoint this.app.post('/api/v1/admin/whitelist/check', (req, res) => { const { email } = req.body; if (!email) { return res.status(400).json({ success: false, error: { message: 'Email is required' } }); } // Mock whitelist check - some emails are "whitelisted" const whitelistedEmails = ['vip@example.com', 'admin@example.com', 'premium@example.com']; const isWhitelisted = whitelistedEmails.includes(email.toLowerCase()); res.json({ success: true, data: { email, whitelisted: isWhitelisted, reason: isWhitelisted ? 'VIP access granted' : 'Standard access only' } }); }); // Admin pricing update endpoint this.app.post('/api/v1/admin/pricing/update', (req, res) => { const { product_id, new_price, currency = 'usd' } = req.body; if (!product_id || !new_price) { return res.status(400).json({ success: false, error: { message: 'Product ID and new price are required' } }); } res.json({ success: true, data: { product_id, old_price: 999, new_price, currency, updated_at: new Date().toISOString() } }); }); // Admin product sync endpoint - REAL or MOCK based on config this.app.post('/api/v1/admin/products/sync', async (req, res) => { try { if (this.stripeMode === 'real') { // Get local products from admin manager const localProducts = this.adminManager.listProducts(); const syncResults = []; for (const product of localProducts) { try { // Check if product already exists in Stripe by searching metadata let stripeProduct; const existingProducts = await stripe.products.list({ limit: 100, expand: ['data.default_price'] }); stripeProduct = existingProducts.data.find(p => p.metadata && p.metadata.spaps_id === product.id ); if (!stripeProduct) { // Create new product in Stripe stripeProduct = await stripe.products.create({ name: product.name, description: product.description, metadata: { spaps_managed: 'true', created_by: 'spaps_admin', spaps_id: product.id } }); // Create corresponding price await stripe.prices.create({ product: stripeProduct.id, unit_amount: product.price, currency: product.currency, metadata: { spaps_managed: 'true', spaps_price_id: product.price_id } }); syncResults.push({ id: product.id, name: product.name, action: 'created', stripe_id: stripeProduct.id }); } else { // Update existing product await stripe.products.update(stripeProduct.id, { name: product.name, description: product.description, active: product.active !== false }); syncResults.push({ id: product.id, name: product.name, action: 'updated', stripe_id: stripeProduct.id }); } } catch (productError) { console.error(`Error syncing product ${product.id}:`, productError); syncResults.push({ id: product.id, name: product.name, action: 'error', error: productError.message }); } } res.json({ success: true, message: `Successfully synced ${syncResults.filter(r => r.action !== 'error').length} products to Stripe`, data: { synced_count: syncResults.filter(r => r.action !== 'error').length, total_count: localProducts.length, results: syncResults } }); } else { // Mock response (fallback) res.json({ success: true, message: 'Products synced successfully (mock mode)', data: { synced_count: 2, products: ['Validate', 'Prototype'] } }); } } catch (error) { console.error('Product sync error:', error); res.status(500).json({ success: false, error: { code: 'SYNC_ERROR', message: error.message || 'Failed to sync products' } }); } }); // Mock usage endpoints this.app.get('/api/usage/balance', (req, res) => { res.json({ balance: 1000, currency: 'credits', updated_at: new Date().toISOString() }); }); // Documentation endpoint: prefer Swagger UI bound to /openapi.json this.app.get('/docs', (req, res) => { if (swaggerUiDist && typeof swaggerUiDist.getAbsoluteFSPath === 'function') { res.type('html').send(` <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>SPAPS API Docs</title> <link rel="stylesheet" href="/swagger-ui/swagger-ui.css" /> <style>body { margin: 0; } #swagger-ui { max-width: 100%; }</style> </head> <body> <div id="swagger-ui"></div> <script src="/swagger-ui/swagger-ui-bundle.js"></script> <script src="/swagger-ui/swagger-ui-standalone-preset.js"></script> <script> window.onload = () => { window.ui = SwaggerUIBundle({ url: '/openapi.json', dom_id: '#swagger-ui', deepLinking: true, presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset], layout: 'BaseLayout' }); }; </script> </body> </html>`); } else { // Fallback to the existing docs page if Swagger UI is not available const msg = 'Using fallback docs page. Install Swagger UI assets for the full API explorer: npm install swagger-ui-dist'; res.send(generateDocsHTML(this.port, msg)); } }); } async loadOpenApiSpec() { // Try YAML OpenAPI const yamlCandidates = [ path.resolve(process.cwd(), 'docs/api-reference.yaml'), path.resolve(__dirname, '../../../docs/api-reference.yaml') ]; for (const p of yamlCandidates) { try { if (fs.existsSync(p)) { let yaml; try { yaml = require('js-yaml'); } catch {} if (yaml) { const content = fs.readFileSync(p, 'utf8'); const parsed = yaml.load(content); // ensure servers list points to local parsed.servers = [{ url: `http://localhost:${this.port}` }]; return parsed; } } } catch {} } // Fallback: build from manifest const manifestCandidates = [ path.resolve(process.cwd(), 'docs/manifest.json'), path.resolve(__dirname, '../../../docs/manifest.json') ]; for (const p of manifestCandidates) { try { if (fs.existsSync(p)) { const manifest = JSON.parse(fs.readFileSync(p, 'utf8')); return this.buildOpenApiFromManifest(manifest); } } catch {} } // Last resort: minimal stub return { openapi: '3.0.0', info: { title: 'SPAPS Local API', version: '0.0.0' }, servers: [{ url: `http://localhost:${this.port}` }], paths: { '/health': { get: { summary: 'Health', responses: { '200': { description: 'OK' } } } } } }; } buildOpenApiFromManifest(manifest) { const spec = { openapi: '3.0.0', info: { title: 'SPAPS API', version: String(manifest.version || '1.0.0') }, servers: [{ url: `http://localhost:${this.port}` }], paths: {} }; const toPath = (p) => p.replace(/:(\w+)/g, '{$1}'); for (const ep of manifest.endpoints || []) { const pathKey = toPath(ep.path); if (!spec.paths[pathKey]) spec.paths[pathKey] = {}; spec.paths[pathKey][String(ep.method || 'GET').toLowerCase()] = { summary: ep.description || `${ep.method} ${ep.path}`, tags: ep.tags || [], responses: { '200': { description: 'OK' } } }; } return spec; } seedDemoData() { // Add a couple of customers and a completed + pending order if none exist const existingCustomers = this.adminManager.listCustomers(); const products = this.adminManager.listProducts(); if (existingCustomers.length === 0 && products.length > 0) { const alice = this.adminManager.createCustomer({ email: 'alice@example.com', name: 'Alice' }); const bob = this.adminManager.createCustomer({ email: 'bob@example.com', name: 'Bob' }); const p = products[0]; const order1 = this.adminManager.createOrder({ customer_id: alice.id, customer_email: alice.email, product_id: p.id, price_id: p.price_id, amount: p.price, currency: p.currency }); this.adminManager.updateOrderStatus(order1.id, 'completed'); this.adminManager.createOrder({ customer_id: bob.id, customer_email: bob.email, product_id: p.id, price_id: p.price_id, amount: p.price, currency: p.currency }); if (!this.json) console.log(chalk.gray('🌱 Seeded demo customers and orders')); } } setupStripeRoutes() { // Enhanced Stripe Product Management - Full CRUD // GET /api/stripe/products - List all products with filtering this.app.get('/api/stripe/products', async (req, res) => { try { if (this.stripeMode === 'real') { const { active, category, limit = '100' } = req.query; const products = await stripe.products.list({ active: active === 'true' ? true : active === 'false' ? false : undefined, limit: parseInt(limit), expand: ['data.default_price'] }); // Filter out non-SPAPS managed products if needed let filteredProducts = products.data; if (category === 'spaps') { filteredProducts = products.data.filter(p => p.metadata && p.metadata.spaps_managed === 'true' ); } res.json({ success: true, data: { products: filteredProducts.map(product => ({ id: product.id, name: product.name, description: product.description, images: product.images, active: product.active, metadata: product.metadata, default_price: product.default_price ? { id: product.default_price.id, unit_amount: product.default_price.unit_amount, currency: product.default_price.currency, recurring: product.default_price.recurring } : null, created: product.created, updated: product.updated })), has_more: products.has_more, total_count: filteredProducts.length } }); } else { // Mock response const localProducts = this.adminManager.listProducts(); res.json({ success: true, data: { products: localProducts, has_more: false, total_count: localProducts.length } }); } } catch (error) { console.error('List products error:', error); res.status(500).json({ success: false, error: { code: 'LIST_PRODUCTS_ERROR', message: error.message || 'Failed to list products' } }); } }); // POST /api/stripe/products - Create new product with optional price this.app.post('/api/stripe/products', async (req, res) => { try { const { name, description, images, metadata = {}, active = true, price, currency = 'usd' } = req.body; if (!name) { return res.status(400).json({ success: false, error: { message: 'Product name is required' } }); } if (this.stripeMode === 'real') { // Create product in Stripe const stripeProduct = await stripe.products.create({ name, description, images, active, metadata: { ...metadata, spaps_managed: 'true', created_by: 'spaps_cli' } }); let stripePrice = null; // If price is provided, create a price for the product if (price !== undefined && price !== null && price !== '') { stripePrice = await stripe.prices.create({ product: stripeProduct.id, unit_amount: parseInt(price), // Price in cents currency: currency, metadata: { spaps_managed: 'true', created_by: 'spaps_cli' } }); // Update product with default price await stripe.products.update(stripeProduct.id, { default_price: stripePrice.id }); } // Fetch the updated product with default_price const updatedProduct = await stripe.products.retrieve(stripeProduct.id, { expand: ['default_price'] }); res.json({ success: true, data: { product: { id: updatedProduct.id, name: updatedProduct.name, description: updatedProduct.description, images: updatedProduct.images, active: updatedProduct.active, metadata: updatedProduct.metadata, created: updatedProduct.created, default_price: updatedProduct.default_price ? { id: updatedProduct.default_price.id, unit_amount: updatedProduct.default_price.unit_amount, currency: updatedProduct.default_price.currency } : null } } }); } else { // Create locally const productPrice = price ? parseInt(price) : 0; const product = this.adminManager.createProduct({ name, description, price: productPrice, currency: currency }); // Add mock default_price structure for consistency product.default_price = productPrice > 0 ? { id: `price_${product.id}`, unit_amount: productPrice, currency: currency } : null; res.json({ success: true, data: { product } }); } } catch (error) { console.error('Create product error:', error); res.status(500).json({ success: false, error: { code: 'CREATE_PRODUCT_ERROR', message: error.message || 'Failed to create product' } }); } }); // PUT /api/stripe/products/:productId - Update product this.app.put('/api/stripe/products/:productId', async (req, res) => { try { const { productId } = req.params; const { name, description, images, metadata, active } = req.body; if (this.stripeMode === 'real') { // Update in Stripe const updateData = {}; if (name !== undefined) updateData.name = name; if (description !== undefined) updateData.description = description; if (images !== undefined) updateData.images = images; if (metadata !== undefined) updateData.metadata = metadata; if (active !== undefined) updateData.active = active; const stripeProduct = await stripe.products.update(productId, updateData); res.json({ success: true, data: { product: { id: stripeProduct.id, name: stripeProduct.name, description: stripeProduct.description, images: stripeProduct.images, active: stripeProduct.active, metadata: stripeProduct.metadata, updated: stripeProduct.updated } } }); } else { // Update locally const product = this.adminManager.updateProduct(productId, { name, description, active }); res.json({ success: true, data: { product } }); } } catch (error) { console.error('Update product error:', error); res.status(500).json({ success: false, error: { code: 'UPDATE_PRODUCT_ERROR', message: error.message || 'Failed to update product' } }); } }); // POST /api/stripe/prices - Create a new price for a product this.app.post('/api/stripe/prices', async (req, res) => { try { const { product_id, unit_amount, currency = 'usd', recurring, metadata = {} } = req.body; if (!product_id || !unit_amount) { return res.status(400).json({ success: false, error: { message: 'Product ID and unit amount are required' } }); } if (this.stripeMode === 'real') { const priceData = { product: product_id, unit_amount: parseInt(unit_amount), currency, metadata: { ...metadata, spaps_managed: 'true' } }; // Add recurring if specified if (recurring) { priceData.recurring = recurring; } const stripePrice = await stripe.prices.create(priceData); res.json({ success: true, data: { price: { id: stripePrice.id, product: stripePrice.product, unit_amount: stripePrice.unit_amount, currency: stripePrice.currency, recurring: stripePrice.recurring, metadata: stripePrice.metadata } } }); } else { // Mock price creation const price = { id: `price_${Date.now()}`, product: product_id, unit_amount: parseInt(unit_amount), currency, recurring, metadata }; res.json({ success: true, data: { price } }); } } catch (error) { console.error('Create price error:', error); res.status(500).json({ success: false, error: { code: 'CREATE_PRICE_ERROR', message: error.message || 'Failed to create price' } }); } }); // PUT /api/stripe/products/:productId/default-price - Update product's default price this.app.put('/api/stripe/products/:productId/default-price', async (req, res) => { try { const { productId } = req.params; const { price_id } = req.body; if (!price_id) { return res.status(400).json({ success: false, error: { message: 'Price ID is required' } }); } if (this.stripeMode === 'real') { const stripeProduct = await stripe.products.update(productId, { default_price: price_id }); res.json({ success: true, data: { product: { id: stripeProduct.id, name: stripeProduct.name, default_price: stripeProduct.default_price } } }); } else { res.json({ success: true, data: { product: { id: productId, default_price: price_id } } }); } } catch (error) { console.error('Update default price error:', error); res.status(500).json({ success: false, error: { code: 'UPDATE_DEFAULT_PRICE_ERROR', message: error.message || 'Failed to update default price' } }); } }); // DELETE /api/stripe/products/:productId - Archive product this.app.delete('/api/stripe/products/:productId', async (req, res) => { try { const { productId } = req.params; if (this.stripeMode === 'real') { // Archive in Stripe (can't truly delete) const stripeProduct = await stripe.products.update(productId, { active: false }); res.json({ success: true, message: 'Product archived successfully', data: { product: { id: stripeProduct.id, active: stripeProduct.active, updated: stripeProduct.updated } } }); } else { // Soft delete locally const result = this.adminManager.deleteProduct(productId); res.json({ success: true, message: 'Product deleted successfully', data: result }); } } catch (error) { console.error('Delete product error:', error); res.status(500).json({ success: false, error: { code: 'DELETE_PRODUCT_ERROR', message: error.message || 'Failed to delete product' } }); } }); // Mock Stripe checkout session this.app.post('/api/stripe/create-checkout-session', async (req, res) => { const { price_id, success_url, cancel_url } = req.body; const sessionId = 'cs_local_' + Date.now(); res.json({ sessionId, url: `http://localhost:${this.port}/checkout/${sessionId}?success=${encodeURIComponent(success_url)}&cancel=${encodeURIComponent(cancel_url)}` }); // Simulate webhook after delay setTimeout(async () => { try { await this.simulateCheckoutWebhook(sessionId, price_id); if (!this.json) { console.log(chalk.blue(`⚡ Webhook simulated: checkout.session.completed`)); } } catch (error) { console.error(chalk.red('Webhook simulation failed:'), error); } }, 2000); }); // Mock checkout page this.app.get('/checkout/:sessionId', (req, res) => { const { sessionId } = req.params; const { success, cancel } = req.query; res.send(` <!DOCTYPE html> <html> <head> <title>SPAPS Local - Mock Checkout</title> <style> body { font-family: system-ui; max-width: 400px; margin: 100px auto; padding: 2rem; } button { width: 100%; padding: 1rem; margin: 0.5rem 0; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; } .pay { background: #635bff; color: white; } .pay:hover { background: #4b41e0; } .cancel { background: #f5f5f5; } .cancel:hover { background: #e5e5e5; } </style> </head> <body> <h1>🍠 Mock Checkout</h1> <p>Session: ${sessionId}</p> <p>This is a mock checkout page for local development.</p> <button class="pay" onclick="window.location='${success}'"> 💳 Complete Payment </button> <button class="cancel" onclick="window.location='${cancel}'"> Cancel </button> <p style="margin-top: 2rem; color: #666; font-size: 14px;"> In production, this would be a real Stripe Checkout page. </p> </body> </html> `); }); // Mock customer portal page this.app.get('/customer-portal', (req, res) => { const { return: returnUrl } = req.query; res.send(` <!DOCTYPE html> <html> <head> <title>SPAPS Local - Customer Portal</title> <style> body { font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 2rem; } button { width: 100%; padding: 1rem; margin: 0.5rem 0; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; } .primary { background: #635bff; color: white; } .primary:hover { background: #4b41e0; } .secondary { background: #f5f5f5; } .secondary:hover { background: #e5e5e5; } .section { background: #f9f9f9; padding: 1.5rem; margin: 1rem 0; border-radius: 8px; } </style> </head> <body> <h1>🍠 Customer Portal (Local)</h1> <p>Manage your subscription and billing information.</p> <div class="section"> <h3>Current Subscription</h3> <p><strong>Plan:</strong> Premium Plan</p> <p><strong>Status:</strong> Active</p> <p><strong>Next billing:</strong> ${new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toLocaleDateString()}</p> </div> <div class="section"> <h3>Payment Method</h3> <p><strong>Card:</strong> •••• •••• •••• 4242</p> <p><strong>Expires:</strong> 12/2025</p> <button class="secondary">Update Payment Method</button> </div> <div class="section"> <h3>Billing History</h3> <p>• $25.00 - Premium Plan (${new Date().toLocaleDateString()})</p> <p>• $25.00 - Premium Plan (${new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toLocaleDateString()})</p> <button class="secondary">Download All Invoices</button> </div> <button class="primary" onclick="window.location='${returnUrl || 'http://localhost:3000'}'"> Return to Application </button> <p style="margin-top: 2rem; color: #666; font-size: 14px;"> This is a mock customer portal for local development. </p> </body> </html> `); }); // Stripe webhook endpoint - REAL or MOCK based on config this.app.post('/api/stripe/webhooks', express.raw({ type: 'application/json' }), (req, res) => { try { let event; if (this.stripeMode === 'real') { // Real Stripe webhook verification const sig = req.headers['stripe-signature']; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; if (webhookSecret && sig) { try { event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret); } catch (err) { console.error('Webhook signature verification failed:', err.message); return res.status(400).send(`Webhook Error: ${err.message}`); } } else { // For local development without webhook secret event = JSON.parse(req.body.toString()); } } else { // Mock mode - accept all webhooks if (Buffer.isBuffer(req.body)) { event = JSON.parse(req.body.toString()); } else if (typeof req.body === 'string') { event = JSON.parse(req.body); } else { event = req.body; } } if (!this.json) { console.log(chalk.blue(`⚡ Webhook received: ${event.type}`)); } // Handle the event switch (event.type) { case 'checkout.session.completed': const session = event.data.object; console.log(chalk.green(`✅ Payment successful: ${session.id}`)); break; case 'payment_intent.succeeded': const paymentIntent = event.data.object; console.log(chalk.green(`💰 Payment intent succeeded: ${paymentIntent.id}`)); break; case 'customer.subscription.created': const subscription = event.data.object; console.log(chalk.green(`📋 Subscription created: ${subscription.id}`)); break; default: console.log(chalk.yellow(`🔔 Unhandled event type: ${event.type}`)); } // Store for testing this.lastWebhookEvent = event; res.json({ received: true }); } catch (error) { console.error('Webhook processing error:', error); res.status(500).json({ error: error.message }); } }); // Webhook testing UI this.app.get('/api/stripe/webhooks/test', (req, res) => { res.send(` <!DOCTYPE html> <html> <head> <title>SPAPS - Stripe Webhook Tester</title> <style> body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 2rem; } button { background: #635bff; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 6px; cursor: pointer; margin: 0.25rem; } button:hover { background: #4b41e0; } .event { background: #f9f9f9; padding: 1rem; margin: 1rem 0; border-radius: 8px; border-left: 4px solid #635bff; } </style> </head> <body> <h1>🍠 Stripe Webhook Tester</h1> <h2>Simulate Events</h2> <div> <button onclick="simulate('checkout.session.completed')">Checkout Completed</button> <button onclick="simulate('payment_intent.succeeded')">Payment Success</button> <button onclick="simulate('customer.subscription.created')">Subscription Created</button> </div> <h2>Last Event</h2> <div id="lastEvent">No events yet</div> <script> async function simulate(type) { const response = await fetch('/api/stripe/webhooks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'evt_local_' + Date.now(), type: type, data: { object: { id: type.split('.')[0] + '_' + Date.now() } } }) }); if (response.ok) { document.getElementById('lastEvent').innerHTML = '<div class="event">✅ ' + type + ' - ' + new Date().toLocaleTimeString() + '</div>'; } } </script> </body> </html> `); }); } async simulateCheckoutWebhook(sessionId, priceId) { const event = { id: 'evt_local_' + Date.now(), type: 'checkout.session.completed', data: { object: { id: sessionId, amount_total: this.getPriceAmount(priceId), currency: 'usd', customer: 'cus_local_' + Date.now(), payment_status: 'paid', status: 'complete', metadata: { app_id: 'local-app-001', price_id: priceId } } } }; // Send to webhook endpoint const response = await fetch(`http://localhost:${this.port}/api/stripe/webhooks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(event) }); return response.ok; } getPriceAmount(priceId) { const prices = { 'price_local_validate': 50000, 'price_local_prototype': 250000, 'price_local_strategy': 1000000, 'price_local_build': 2500000 }; return prices[priceId] || 10000; } setupAdminRoutes() { // Admin API endpoints // List products this.app.get('/api/admin/products', (req, res) => { const products = this.adminManager.listProducts(); res.json({ success: true, data: products }); }); // Get single product this.app.get('/api/admin/products/:id', (req, res) => { try { const product = this.adminManager.getProduct(req.params.id); if (!product) { return res.status(404).json({ success: false, error: 'Product not found' }); } res.json({ success: true, data: product }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // Create product this.app.post('/api/admin/products', (req, res) => { try { const product = this.adminManager.createProduct(req.body); res.json({ success: true, data: product }); } catch (error) { res.status(400).json({ success: false, error: error.message }); } }); // Update product this.app.put('/api/admin/products/:id', (req, res) => { try { const product = this.adminManager.updateProduct(req.params.id, req.body); res.json({ success: true, data: product }); } catch (error) { res.status(400).json({ success: false, error: error.message }); } }); // Delete product this.app.delete('/api/admin/products/:id', (req, res) => { try { const result = this.adminManager.deleteProduct(req.params.id); res.json(result); } catch (error) { res.status(400).json({ success: false, error: error.message }); } }); // List orders this.app.get('/api/admin/orders', (req, res) => { const orders = this.adminManager.listOrders(req.query); res.json({ success: true, data: orders }); }); // Create order (for testing) this.app.post('/api/admin/orders', (req, res) => { try { const order = this.adminManager.createOrder(req.body); res.json({ success: true, data: order }); } catch (error) { res.status(400).json({ success: false, error: error.message }); } }); // Update order status this.app.patch('/api/admin/orders/:id/status', (req, res) => { try { const order = this.adminManager.updateOrderStatus(req.params.id, req.body.status); res.json({ success: true, data: order }); } catch (error) { res.status(400).json({ success: false, error: error.message }); } }); // Analytics dashboard this.app.get('/api/admin/analytics', (req, res) => { const analytics = this.adminManager.getAnalytics(); res.json({ success: true, data: analytics }); }); // Export data (for migration to production) this.app.get('/api/admin/export', (req, res) => { const data = this.adminManager.exportData(); res.json({ success: true, data }); }); // Import data this.app.post('/api/admin/import', (req, res) => { try { const result = this.adminManager.importData(req.body); res.json({ success: true, data: result }); } catch (error) { res.status(400).json({ success: false, error: error.message }); } }); // Admin UI Dashboard this.app.get('/admin', (req, res) => { const analytics = this.adminManager.getAnalytics(); const products = this.adminManager.listProducts(); res.send(` <!DOCTYPE html> <html> <head> <title>SPAPS Admin - Local Mode</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui; background: #f5f5f5; } .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 2rem; } .container { max-width: 1200px; margin: 0 auto; padding: 2rem; } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; margin: 2rem 0; } .card { background: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .card h3 { margin-bottom: 1rem; color: #333; } .stat { font-size: 2rem; font-weight: bold; color: #667eea; } .label { color: #666; font-size: 0.9rem; } table { width: 100%; border-collapse: collapse; margin-top: 1rem; } th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #eee; } th { background: #f9f9f9; font-weight: 600; } .btn { background: #667eea; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; } .btn:hover { background: #764ba2; } .badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.85