- Hero mockup: enhanced 3D perspective and shadow - Testimonials: illustrated SVG avatars - Growth pricing card: visual prominence (scale, gradient, badge) - Most Popular badge: repositioned to avoid overlapping heading - Nav: added Log In link next to Start Free Trial - Fixed btn-primary text colour on anchor tags (white on blue) - Fixed cursor: default on all non-interactive elements - Disabled user-select on non-form content to prevent text caret
282 lines
7.4 KiB
JavaScript
282 lines
7.4 KiB
JavaScript
import Stripe from 'stripe';
|
|
import dotenv from 'dotenv';
|
|
|
|
dotenv.config();
|
|
|
|
let stripe = null;
|
|
try {
|
|
if (process.env.STRIPE_SECRET_KEY && !process.env.STRIPE_SECRET_KEY.includes("placeholder")) {
|
|
stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
|
} else {
|
|
console.warn("Stripe not configured - billing endpoints will return 503");
|
|
}
|
|
} catch (e) {
|
|
console.warn("Stripe init failed:", e.message);
|
|
}
|
|
|
|
// Map plan names to Stripe Price IDs
|
|
const planPriceMap = {
|
|
'starter': process.env.STRIPE_PRICE_STARTER,
|
|
'growth': process.env.STRIPE_PRICE_GROWTH,
|
|
'pro': process.env.STRIPE_PRICE_PRO
|
|
|
|
};
|
|
|
|
// Plan metadata
|
|
const planMetadata = {
|
|
'starter': { tier: 'starter', amount: 3900, name: 'Starter' },
|
|
'growth': { tier: 'growth', amount: 9900, name: 'Growth' },
|
|
'pro': { tier: 'pro', amount: 24900, name: 'Pro' }
|
|
};
|
|
|
|
/**
|
|
* Create or retrieve Stripe customer for a user
|
|
*/
|
|
export async function getOrCreateStripeCustomer(pool, userId, email) {
|
|
try {
|
|
// Check if subscription already exists
|
|
const subResult = await pool.query(
|
|
'SELECT stripe_customer_id FROM subscriptions WHERE user_id = $1',
|
|
[userId]
|
|
);
|
|
|
|
if (subResult.rows.length > 0) {
|
|
return subResult.rows[0].stripe_customer_id;
|
|
}
|
|
|
|
// Create new customer
|
|
const customer = await stripe.customers.create({
|
|
email,
|
|
metadata: { user_id: userId }
|
|
});
|
|
|
|
return customer.id;
|
|
} catch (error) {
|
|
console.error('Error getting/creating Stripe customer:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a checkout session for a selected plan
|
|
*/
|
|
export async function createCheckoutSession(pool, userId, email, plan, successUrl, cancelUrl) {
|
|
try {
|
|
if (!planPriceMap[plan]) {
|
|
throw new Error(`Invalid plan: ${plan}`);
|
|
}
|
|
|
|
const customerId = await getOrCreateStripeCustomer(pool, userId, email);
|
|
|
|
const session = await stripe.checkout.sessions.create({
|
|
customer: customerId,
|
|
mode: 'subscription',
|
|
payment_method_types: ['card'],
|
|
line_items: [
|
|
{
|
|
price: planPriceMap[plan],
|
|
quantity: 1
|
|
}
|
|
],
|
|
subscription_data: {
|
|
trial_period_days: 14,
|
|
metadata: {
|
|
user_id: userId,
|
|
plan: plan
|
|
}
|
|
},
|
|
success_url: successUrl,
|
|
cancel_url: cancelUrl,
|
|
metadata: {
|
|
user_id: userId,
|
|
plan: plan
|
|
}
|
|
});
|
|
|
|
return session;
|
|
} catch (error) {
|
|
console.error('Error creating checkout session:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle Stripe webhook events
|
|
*/
|
|
export async function handleWebhookEvent(pool, event) {
|
|
try {
|
|
const { type, data } = event;
|
|
const object = data.object;
|
|
|
|
console.log(`Processing webhook event: ${type}`);
|
|
|
|
switch (type) {
|
|
case 'checkout.session.completed': {
|
|
const session = object;
|
|
const { user_id, plan } = session.metadata;
|
|
|
|
// Create/update subscription record
|
|
const result = await pool.query(
|
|
`INSERT INTO subscriptions (user_id, stripe_customer_id, plan, status, trial_start, trial_end)
|
|
VALUES ($1, $2, $3, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '14 days')
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
stripe_customer_id = $2, plan = $3, status = 'active', trial_start = CURRENT_TIMESTAMP,
|
|
trial_end = CURRENT_TIMESTAMP + INTERVAL '14 days', updated_at = CURRENT_TIMESTAMP
|
|
RETURNING *`,
|
|
[user_id, session.customer, plan]
|
|
);
|
|
|
|
// Update user tier
|
|
await pool.query(
|
|
'UPDATE users SET tier = $1 WHERE id = $2',
|
|
[plan, user_id]
|
|
);
|
|
|
|
console.log(`Subscription created for user ${user_id} on plan ${plan}`);
|
|
return result.rows[0];
|
|
}
|
|
|
|
case 'customer.subscription.updated': {
|
|
const subscription = object;
|
|
const { user_id, plan } = subscription.metadata;
|
|
|
|
// Update subscription record
|
|
const result = await pool.query(
|
|
`UPDATE subscriptions SET
|
|
stripe_subscription_id = $1,
|
|
plan = $2,
|
|
status = $3,
|
|
current_period_start = to_timestamp($4),
|
|
current_period_end = to_timestamp($5),
|
|
trial_end = $6,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE user_id = $7
|
|
RETURNING *`,
|
|
[
|
|
subscription.id,
|
|
plan || 'unknown',
|
|
subscription.status,
|
|
subscription.current_period_start,
|
|
subscription.current_period_end,
|
|
subscription.trial_end ? new Date(subscription.trial_end * 1000) : null,
|
|
user_id
|
|
]
|
|
);
|
|
|
|
console.log(`Subscription updated for user ${user_id}`);
|
|
return result.rows[0];
|
|
}
|
|
|
|
case 'customer.subscription.deleted': {
|
|
const subscription = object;
|
|
const { user_id } = subscription.metadata;
|
|
|
|
// Update subscription status
|
|
const result = await pool.query(
|
|
`UPDATE subscriptions SET
|
|
status = 'cancelled',
|
|
stripe_subscription_id = NULL,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE user_id = $1
|
|
RETURNING *`,
|
|
[user_id]
|
|
);
|
|
|
|
// Reset user tier to free
|
|
await pool.query(
|
|
'UPDATE users SET tier = $1 WHERE id = $2',
|
|
['free', user_id]
|
|
);
|
|
|
|
console.log(`Subscription cancelled for user ${user_id}`);
|
|
return result.rows[0];
|
|
}
|
|
|
|
case 'invoice.payment_failed': {
|
|
const invoice = object;
|
|
const subscription = invoice.subscription;
|
|
|
|
// Get user_id from subscription metadata
|
|
const sub = await stripe.subscriptions.retrieve(subscription);
|
|
const { user_id } = sub.metadata;
|
|
|
|
console.log(`Payment failed for user ${user_id}`);
|
|
// Could send alert email here, etc.
|
|
return sub;
|
|
}
|
|
|
|
default:
|
|
console.log(`Unhandled event type: ${type}`);
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error handling webhook event:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current subscription status for a user
|
|
*/
|
|
export async function getSubscriptionStatus(pool, userId) {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT * FROM subscriptions WHERE user_id = $1`,
|
|
[userId]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return result.rows[0];
|
|
} catch (error) {
|
|
console.error('Error fetching subscription status:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a Stripe Customer Portal session
|
|
*/
|
|
export async function createPortalSession(pool, userId, returnUrl) {
|
|
try {
|
|
const subscription = await getSubscriptionStatus(pool, userId);
|
|
|
|
if (!subscription) {
|
|
throw new Error('No subscription found for user');
|
|
}
|
|
|
|
const session = await stripe.billingPortal.sessions.create({
|
|
customer: subscription.stripe_customer_id,
|
|
return_url: returnUrl
|
|
});
|
|
|
|
return session;
|
|
} catch (error) {
|
|
console.error('Error creating portal session:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify webhook signature
|
|
*/
|
|
export function verifyWebhookSignature(body, signature, secret) {
|
|
try {
|
|
return stripe.webhooks.constructEvent(body, signature, secret);
|
|
} catch (error) {
|
|
console.error('Webhook signature verification failed:', error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export default {
|
|
getOrCreateStripeCustomer,
|
|
createCheckoutSession,
|
|
handleWebhookEvent,
|
|
getSubscriptionStatus,
|
|
createPortalSession,
|
|
verifyWebhookSignature
|
|
};
|