- 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
794 lines
24 KiB
JavaScript
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;
|