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
JavaScript
#!/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