Sergei 24e7f057e7 Stable version: Reusable BeneficiaryMenu, subscription fixes
- Created reusable BeneficiaryMenu component with Modal backdrop
- Menu closes on outside tap (proper Modal + Pressable implementation)
- Removed debug panel from subscription and beneficiary detail pages
- Fixed subscription creation and equipment status handling
- Backend improvements for Stripe integration
2026-01-09 13:22:56 -08:00

794 lines
24 KiB
JavaScript

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;