const express = require('express'); const router = express.Router(); const Stripe = require('stripe'); const { supabase } = require('../config/supabase'); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); /** * POST /api/stripe/create-checkout-session * Creates a Stripe Checkout session for purchasing Starter Kit + optional Premium subscription */ router.post('/create-checkout-session', async (req, res) => { try { const { userId, beneficiaryName, beneficiaryAddress, beneficiaryPhone, beneficiaryNotes, shippingAddress, includePremium = true } = req.body; if (!userId) { return res.status(400).json({ error: 'userId is required' }); } if (!beneficiaryName || !beneficiaryAddress) { return res.status(400).json({ error: 'Beneficiary name and address are required' }); } // Build line items const lineItems = [ { price: process.env.STRIPE_PRICE_STARTER_KIT, quantity: 1, } ]; if (includePremium) { lineItems.push({ price: process.env.STRIPE_PRICE_PREMIUM, quantity: 1, }); } // Create checkout session const sessionConfig = { mode: includePremium ? 'subscription' : 'payment', payment_method_types: ['card'], line_items: lineItems, success_url: `${process.env.FRONTEND_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.FRONTEND_URL}/checkout/cancel`, customer_email: req.body.email, metadata: { userId, beneficiaryName, beneficiaryAddress, beneficiaryPhone: beneficiaryPhone || '', beneficiaryNotes: beneficiaryNotes || '', shippingAddress: JSON.stringify(shippingAddress), includePremium: includePremium ? 'true' : 'false' }, shipping_address_collection: { allowed_countries: ['US', 'CA'] } }; // Only add shipping options for payment mode (not subscription) if (!includePremium) { sessionConfig.shipping_options = [ { shipping_rate_data: { type: 'fixed_amount', fixed_amount: { amount: 0, currency: 'usd' }, display_name: 'Standard shipping', delivery_estimate: { minimum: { unit: 'business_day', value: 5 }, maximum: { unit: 'business_day', value: 7 }, }, }, }, { shipping_rate_data: { type: 'fixed_amount', fixed_amount: { amount: 1500, currency: 'usd' }, display_name: 'Express shipping', delivery_estimate: { minimum: { unit: 'business_day', value: 2 }, maximum: { unit: 'business_day', value: 3 }, }, }, }, ]; } const session = await stripe.checkout.sessions.create(sessionConfig); res.json({ sessionId: session.id, url: session.url }); } catch (error) { console.error('Checkout session error:', error); res.status(500).json({ error: error.message }); } }); /** * POST /api/stripe/create-portal-session * Creates a Stripe Customer Portal session for managing subscriptions */ router.post('/create-portal-session', async (req, res) => { try { const { customerId } = req.body; if (!customerId) { return res.status(400).json({ error: 'customerId is required' }); } const session = await stripe.billingPortal.sessions.create({ customer: customerId, return_url: `${process.env.FRONTEND_URL}/settings`, }); res.json({ url: session.url }); } catch (error) { console.error('Portal session error:', error); res.status(500).json({ error: error.message }); } }); /** * POST /api/stripe/create-payment-sheet * Creates PaymentIntent for in-app Payment Sheet (React Native) */ router.post('/create-payment-sheet', async (req, res) => { try { const { email, amount = 24900 } = req.body; // $249.00 default (Starter Kit) // Create or retrieve customer let customer; const existingCustomers = await stripe.customers.list({ email, limit: 1 }); if (existingCustomers.data.length > 0) { customer = existingCustomers.data[0]; } else { customer = await stripe.customers.create({ email }); } // Create ephemeral key for the customer const ephemeralKey = await stripe.ephemeralKeys.create( { customer: customer.id }, { apiVersion: '2024-12-18.acacia' } ); // Create PaymentIntent const paymentIntent = await stripe.paymentIntents.create({ amount, currency: 'usd', customer: customer.id, automatic_payment_methods: { enabled: true }, metadata: req.body.metadata || {}, }); res.json({ paymentIntent: paymentIntent.client_secret, ephemeralKey: ephemeralKey.secret, customer: customer.id, publishableKey: process.env.STRIPE_PUBLISHABLE_KEY, }); } catch (error) { console.error('Payment sheet error:', error); res.status(500).json({ error: error.message }); } }); /** * GET /api/stripe/products * Returns available products and prices for the frontend */ router.get('/products', async (req, res) => { try { res.json({ starterKit: { name: 'WellNuo Starter Kit', description: '2x Motion Sensors + 1x Door Sensor + 1x Hub', price: 249.00, priceId: process.env.STRIPE_PRICE_STARTER_KIT }, premium: { name: 'WellNuo Premium', description: 'AI Julia assistant, 90-day history, invite up to 5 family members', price: 9.99, interval: 'month', priceId: process.env.STRIPE_PRICE_PREMIUM } }); } catch (error) { res.status(500).json({ error: error.message }); } }); /** * Helper: Get or create Stripe Customer for a beneficiary * Customer is tied to BENEFICIARY (not user) so subscription persists when access is transferred */ async function getOrCreateStripeCustomer(beneficiaryId) { // Get beneficiary from DB (new beneficiaries table) const { data: beneficiary, error } = await supabase .from('beneficiaries') .select('id, name, stripe_customer_id') .eq('id', beneficiaryId) .single(); if (error || !beneficiary) { throw new Error('Beneficiary not found'); } // If already has Stripe customer, return it if (beneficiary.stripe_customer_id) { return beneficiary.stripe_customer_id; } // Create new Stripe customer for this beneficiary const customer = await stripe.customers.create({ name: beneficiary.name || undefined, metadata: { beneficiary_id: beneficiary.id.toString(), type: 'beneficiary' } }); // Save stripe_customer_id to DB await supabase .from('beneficiaries') .update({ stripe_customer_id: customer.id }) .eq('id', beneficiaryId); console.log(`✓ Created Stripe customer ${customer.id} for beneficiary ${beneficiaryId}`); return customer.id; } /** * POST /api/stripe/create-subscription * Creates a Stripe Subscription for a beneficiary * Uses Stripe as the source of truth - no local subscription table needed! */ router.post('/create-subscription', async (req, res) => { try { const { beneficiaryId, paymentMethodId } = req.body; if (!beneficiaryId) { return res.status(400).json({ error: 'beneficiaryId is required' }); } // Get or create Stripe customer for this beneficiary const customerId = await getOrCreateStripeCustomer(beneficiaryId); // Attach payment method to customer if provided if (paymentMethodId) { await stripe.paymentMethods.attach(paymentMethodId, { customer: customerId }); await stripe.customers.update(customerId, { invoice_settings: { default_payment_method: paymentMethodId } }); } // Check if customer already has an active subscription const existingSubs = await stripe.subscriptions.list({ customer: customerId, status: 'active', limit: 1 }); if (existingSubs.data.length > 0) { const sub = existingSubs.data[0]; return res.json({ success: true, subscription: { id: sub.id, status: sub.status, plan: 'premium', currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(), cancelAtPeriodEnd: sub.cancel_at_period_end }, message: 'Subscription already active' }); } // Create new subscription in Stripe const subscription = await stripe.subscriptions.create({ customer: customerId, items: [{ price: process.env.STRIPE_PRICE_PREMIUM }], payment_behavior: 'default_incomplete', payment_settings: { save_default_payment_method: 'on_subscription' }, expand: ['latest_invoice.payment_intent'], metadata: { beneficiary_id: beneficiaryId.toString() } }); console.log(`✓ Created Stripe subscription ${subscription.id} for beneficiary ${beneficiaryId}`); res.json({ success: true, subscription: { id: subscription.id, status: subscription.status, plan: 'premium', currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString() }, clientSecret: subscription.latest_invoice?.payment_intent?.client_secret }); } catch (error) { console.error('Create subscription error:', error); res.status(500).json({ error: error.message }); } }); /** * Helper: Normalize Stripe subscription status to app statuses */ function normalizeStripeStatus(status) { switch (status) { case 'active': return 'active'; case 'trialing': return 'trialing'; case 'past_due': case 'unpaid': return 'past_due'; case 'canceled': return 'canceled'; case 'incomplete': case 'incomplete_expired': return 'expired'; default: return 'none'; } } /** * GET /api/stripe/subscription-status/:beneficiaryId * Gets subscription status directly from Stripe (source of truth) */ router.get('/subscription-status/:beneficiaryId', async (req, res) => { try { const { beneficiaryId } = req.params; // Get beneficiary's stripe_customer_id const { data: beneficiary } = await supabase .from('beneficiaries') .select('stripe_customer_id') .eq('id', beneficiaryId) .single(); if (!beneficiary?.stripe_customer_id) { return res.json({ status: 'none', plan: 'free', hasSubscription: false }); } // Get active subscriptions from Stripe const subscriptions = await stripe.subscriptions.list({ customer: beneficiary.stripe_customer_id, status: 'active', limit: 1 }); if (subscriptions.data.length === 0) { // Check for past_due or canceled const allSubs = await stripe.subscriptions.list({ customer: beneficiary.stripe_customer_id, limit: 1 }); if (allSubs.data.length > 0) { const sub = allSubs.data[0]; const normalizedStatus = normalizeStripeStatus(sub.status); return res.json({ status: normalizedStatus, plan: normalizedStatus === 'canceled' || normalizedStatus === 'none' || normalizedStatus === 'expired' ? 'free' : 'premium', hasSubscription: normalizedStatus === 'active' || normalizedStatus === 'trialing', subscriptionId: sub.id, currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(), cancelAtPeriodEnd: sub.cancel_at_period_end }); } return res.json({ status: 'none', plan: 'free', hasSubscription: false }); } const subscription = subscriptions.data[0]; res.json({ status: 'active', plan: 'premium', hasSubscription: true, subscriptionId: subscription.id, currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(), cancelAtPeriodEnd: subscription.cancel_at_period_end }); } catch (error) { console.error('Get subscription status error:', error); res.status(500).json({ error: error.message }); } }); /** * POST /api/stripe/cancel-subscription * Cancels subscription at period end */ router.post('/cancel-subscription', async (req, res) => { try { const { beneficiaryId } = req.body; console.log('[CANCEL] Request received for beneficiaryId:', beneficiaryId); if (!beneficiaryId) { return res.status(400).json({ error: 'beneficiaryId is required' }); } // Get beneficiary's stripe_customer_id const { data: beneficiary, error: dbError } = await supabase .from('beneficiaries') .select('stripe_customer_id') .eq('id', beneficiaryId) .single(); console.log('[CANCEL] DB result:', { beneficiary, dbError }); if (!beneficiary?.stripe_customer_id) { console.log('[CANCEL] No stripe_customer_id found'); return res.status(404).json({ error: 'No subscription found' }); } // Get active subscription const subscriptions = await stripe.subscriptions.list({ customer: beneficiary.stripe_customer_id, status: 'active', limit: 1 }); if (subscriptions.data.length === 0) { return res.status(404).json({ error: 'No active subscription found' }); } // Cancel at period end (not immediately) const subscription = await stripe.subscriptions.update(subscriptions.data[0].id, { cancel_at_period_end: true }); console.log(`✓ Subscription ${subscription.id} will cancel at period end:`, subscription.current_period_end); const cancelAt = subscription.current_period_end ? new Date(subscription.current_period_end * 1000).toISOString() : null; res.json({ success: true, message: 'Subscription will cancel at the end of the billing period', cancelAt }); } catch (error) { console.error('Cancel subscription error:', error); res.status(500).json({ error: error.message }); } }); /** * POST /api/stripe/reactivate-subscription * Reactivates a subscription that was set to cancel */ router.post('/reactivate-subscription', async (req, res) => { try { const { beneficiaryId } = req.body; if (!beneficiaryId) { return res.status(400).json({ error: 'beneficiaryId is required' }); } const { data: beneficiary } = await supabase .from('beneficiaries') .select('stripe_customer_id') .eq('id', beneficiaryId) .single(); if (!beneficiary?.stripe_customer_id) { return res.status(404).json({ error: 'No subscription found' }); } const subscriptions = await stripe.subscriptions.list({ customer: beneficiary.stripe_customer_id, limit: 1 }); if (subscriptions.data.length === 0) { return res.status(404).json({ error: 'No subscription found' }); } const subscription = await stripe.subscriptions.update(subscriptions.data[0].id, { cancel_at_period_end: false }); console.log(`✓ Subscription ${subscription.id} reactivated`); res.json({ success: true, message: 'Subscription reactivated', status: subscription.status }); } catch (error) { console.error('Reactivate subscription error:', error); res.status(500).json({ error: error.message }); } }); /** * POST /api/stripe/create-subscription-payment-sheet * Creates a SetupIntent for collecting payment method in React Native app * Then creates subscription with that payment method */ router.post('/create-subscription-payment-sheet', async (req, res) => { try { const { beneficiaryId } = req.body; if (!beneficiaryId) { return res.status(400).json({ error: 'beneficiaryId is required' }); } // Get or create Stripe customer for this beneficiary const customerId = await getOrCreateStripeCustomer(beneficiaryId); // Check if already has active subscription const existingSubs = await stripe.subscriptions.list({ customer: customerId, status: 'active', limit: 1 }); if (existingSubs.data.length > 0) { return res.json({ alreadySubscribed: true, subscription: { id: existingSubs.data[0].id, status: 'active', currentPeriodEnd: new Date(existingSubs.data[0].current_period_end * 1000).toISOString() } }); } // Cancel any incomplete subscriptions to avoid duplicates const incompleteSubs = await stripe.subscriptions.list({ customer: customerId, status: 'incomplete', limit: 10 }); for (const sub of incompleteSubs.data) { try { await stripe.subscriptions.cancel(sub.id); console.log(`Canceled incomplete subscription ${sub.id} for customer ${customerId}`); } catch (cancelError) { console.warn(`Failed to cancel incomplete subscription ${sub.id}:`, cancelError.message); } } // Create ephemeral key const ephemeralKey = await stripe.ephemeralKeys.create( { customer: customerId }, { apiVersion: '2024-12-18.acacia' } ); // Create subscription with incomplete status (needs payment) const subscription = await stripe.subscriptions.create({ customer: customerId, items: [{ price: process.env.STRIPE_PRICE_PREMIUM }], payment_behavior: 'default_incomplete', payment_settings: { save_default_payment_method: 'on_subscription', payment_method_types: ['card'] }, expand: ['latest_invoice.payment_intent'], metadata: { beneficiary_id: beneficiaryId.toString() } }); // Try to get payment_intent from expanded invoice let clientSecret = subscription.latest_invoice?.payment_intent?.client_secret; // Stripe SDK v20+ doesn't expose payment_intent field in Invoice object // Need to fetch PaymentIntent via list API as a workaround if (!clientSecret && subscription.latest_invoice) { const invoiceId = typeof subscription.latest_invoice === 'string' ? subscription.latest_invoice : subscription.latest_invoice.id; if (invoiceId) { // List recent PaymentIntents for this customer and find the one for this invoice const paymentIntents = await stripe.paymentIntents.list({ customer: customerId, limit: 5 }); for (const pi of paymentIntents.data) { if (pi.invoice === invoiceId || pi.description?.includes('Subscription')) { clientSecret = pi.client_secret; break; } } } } console.log(`[SUBSCRIPTION] Created subscription ${subscription.id}, clientSecret: ${!!clientSecret}`); res.json({ subscriptionId: subscription.id, clientSecret: clientSecret, ephemeralKey: ephemeralKey.secret, customer: customerId, publishableKey: process.env.STRIPE_PUBLISHABLE_KEY }); } catch (error) { console.error('Create subscription payment sheet error:', error); res.status(500).json({ error: error.message }); } }); /** * POST /api/stripe/confirm-subscription-payment * Confirms the latest invoice PaymentIntent for a subscription if needed */ router.post('/confirm-subscription-payment', async (req, res) => { try { const { subscriptionId } = req.body; if (!subscriptionId) { return res.status(400).json({ error: 'subscriptionId is required' }); } const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ['latest_invoice.payment_intent'] }); const paymentIntent = subscription.latest_invoice?.payment_intent; const paymentIntentId = typeof paymentIntent === 'string' ? paymentIntent : paymentIntent?.id; const paymentIntentStatus = typeof paymentIntent === 'string' ? null : paymentIntent?.status; if (!paymentIntentId) { return res.status(400).json({ error: 'Payment intent not found for subscription' }); } if (paymentIntentStatus === 'requires_confirmation') { const confirmed = await stripe.paymentIntents.confirm(paymentIntentId); return res.json({ success: true, status: confirmed.status }); } return res.json({ success: true, status: paymentIntentStatus || 'unknown' }); } catch (error) { console.error('Confirm subscription payment error:', error); res.status(500).json({ error: error.message }); } }); /** * GET /api/stripe/transaction-history/:beneficiaryId * Gets transaction/invoice history directly from Stripe */ router.get('/transaction-history/:beneficiaryId', async (req, res) => { try { const { beneficiaryId } = req.params; const limit = parseInt(req.query.limit) || 10; // Get beneficiary's stripe_customer_id const { data: beneficiary } = await supabase .from('beneficiaries') .select('stripe_customer_id') .eq('id', beneficiaryId) .single(); if (!beneficiary?.stripe_customer_id) { return res.json({ transactions: [], hasMore: false }); } // Get invoices from Stripe const invoices = await stripe.invoices.list({ customer: beneficiary.stripe_customer_id, limit: limit, expand: ['data.subscription'] }); // Also get PaymentIntents for one-time purchases (equipment) const paymentIntents = await stripe.paymentIntents.list({ customer: beneficiary.stripe_customer_id, limit: limit }); // Format invoices (subscription payments) - only show paid invoices const formattedInvoices = invoices.data .filter(invoice => invoice.status === 'paid' && invoice.amount_paid > 0) .map(invoice => ({ id: invoice.id, type: 'subscription', amount: invoice.amount_paid / 100, currency: invoice.currency.toUpperCase(), status: invoice.status, date: new Date(invoice.created * 1000).toISOString(), description: invoice.lines.data[0]?.description || 'WellNuo Premium', invoicePdf: invoice.invoice_pdf, hostedUrl: invoice.hosted_invoice_url })); // Format payment intents (one-time purchases like equipment) // Exclude subscription-related payments (they're already in invoices) const formattedPayments = paymentIntents.data .filter(pi => { if (pi.status !== 'succeeded') return false; if (pi.invoice) return false; // Has linked invoice // Exclude "Subscription creation" - it's duplicate of invoice if (pi.description === 'Subscription creation') return false; return true; }) .map(pi => ({ id: pi.id, type: 'one_time', amount: pi.amount / 100, currency: pi.currency.toUpperCase(), status: pi.status, date: new Date(pi.created * 1000).toISOString(), description: pi.metadata?.orderType === 'starter_kit' ? 'WellNuo Starter Kit' : (pi.description || 'One-time payment'), receiptUrl: pi.charges?.data[0]?.receipt_url })); // Combine and sort by date (newest first) const allTransactions = [...formattedInvoices, ...formattedPayments] .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); res.json({ transactions: allTransactions, hasMore: invoices.has_more || paymentIntents.has_more }); } catch (error) { console.error('Get transaction history error:', error); res.status(500).json({ error: error.message }); } }); /** * GET /api/stripe/session/:sessionId * Get checkout session details (for success page) */ router.get('/session/:sessionId', async (req, res) => { try { const session = await stripe.checkout.sessions.retrieve(req.params.sessionId, { expand: ['line_items', 'customer', 'subscription'] }); res.json({ id: session.id, status: session.status, paymentStatus: session.payment_status, customerEmail: session.customer_email, amountTotal: session.amount_total / 100, metadata: session.metadata }); } catch (error) { console.error('Get session error:', error); res.status(500).json({ error: error.message }); } }); module.exports = router;