feat: visual polish, nav login link, pricing badge fix, cursor fix, button contrast
- 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
This commit is contained in:
281
stripe-billing.js
Normal file
281
stripe-billing.js
Normal file
@@ -0,0 +1,281 @@
|
||||
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
|
||||
};
|
||||
Reference in New Issue
Block a user