UNPKG

stripe-donation-widget

Version:

A complete, reusable donation system for React/Next.js applications with Stripe integration, real-time MRR tracking, and beautiful UI components.

309 lines (304 loc) 12.9 kB
'use strict'; var Stripe = require('stripe'); var zod = require('zod'); zod.z.object({ stripePublishableKey: zod.z.string().min(1, 'Stripe publishable key is required'), projectName: zod.z.string().min(1, 'Project name is required'), projectSlug: zod.z.string() .min(1, 'Project slug is required') .regex(/^[a-z0-9-]+$/, 'Project slug must contain only lowercase letters, numbers, and hyphens'), monthly: zod.z.object({ suggested: zod.z.number().positive('Monthly suggested amount must be positive'), min: zod.z.number().min(0, 'Monthly minimum amount must be non-negative').optional(), max: zod.z.number().positive().optional(), }).refine(data => !data.max || !data.min || data.max >= data.min, { message: 'Monthly maximum must be greater than or equal to minimum', }), annual: zod.z.object({ suggested: zod.z.number().positive('Annual suggested amount must be positive'), min: zod.z.number().min(0, 'Annual minimum amount must be non-negative').optional(), max: zod.z.number().positive().optional(), }).refine(data => !data.max || !data.min || data.max >= data.min, { message: 'Annual maximum must be greater than or equal to minimum', }), goal: zod.z.object({ target: zod.z.number().positive('Goal target must be positive'), current: zod.z.number().min(0, 'Goal current amount must be non-negative').optional().default(0), showProgress: zod.z.boolean().optional().default(true), description: zod.z.string().optional(), calculateFromSubscriptions: zod.z.boolean().optional().default(false), }).optional(), theme: zod.z.enum(['light', 'dark', 'auto']).optional().default('auto'), customAmounts: zod.z.boolean().optional().default(true), successUrl: zod.z.string().url().optional(), cancelUrl: zod.z.string().url().optional(), currency: zod.z.string().length(3).optional().default('usd'), }); function generateProductIds(projectSlug) { return { monthly: `${projectSlug}-monthly-donation`, annual: `${projectSlug}-annual-donation`, }; } class DonationStripeManager { constructor(secretKey) { this.stripe = new Stripe(secretKey, { apiVersion: '2023-10-16', }); } /** * Ensure donation products exist in Stripe, create them if they don't */ async ensureProductsExist(projectSlug, projectName) { const productIds = generateProductIds(projectSlug); // Check if products already exist const existingProducts = await this.stripe.products.list({ ids: [productIds.monthly, productIds.annual], }); const existingProductMap = new Map(existingProducts.data.map((product) => [product.id, product])); // Create monthly product if it doesn't exist let monthlyProduct = existingProductMap.get(productIds.monthly); if (!monthlyProduct) { monthlyProduct = await this.stripe.products.create({ id: productIds.monthly, name: `${projectName} - Monthly Support`, description: `Monthly recurring donation to support ${projectName}`, type: 'service', }); } // Create annual product if it doesn't exist let annualProduct = existingProductMap.get(productIds.annual); if (!annualProduct) { annualProduct = await this.stripe.products.create({ id: productIds.annual, name: `${projectName} - Annual Support`, description: `Annual recurring donation to support ${projectName}`, type: 'service', }); } // Get or create prices for the products const monthlyPriceId = await this.getOrCreatePrice(monthlyProduct.id, 'month'); const annualPriceId = await this.getOrCreatePrice(annualProduct.id, 'year'); return { monthlyPriceId, annualPriceId, }; } /** * Get or create a price for a product */ async getOrCreatePrice(productId, interval) { // Check if a price already exists for this product const existingPrices = await this.stripe.prices.list({ product: productId, active: true, }); const existingPrice = existingPrices.data.find((price) => price.recurring?.interval === interval); if (existingPrice) { return existingPrice.id; } // Create a new price (we'll use a placeholder amount that gets overridden in checkout) const price = await this.stripe.prices.create({ product: productId, currency: 'usd', recurring: { interval, }, unit_amount: 100, // $1.00 placeholder - actual amount set in checkout }); return price.id; } /** * Create a checkout session for donations */ async createCheckoutSession(sessionData) { // Create checkout session with dynamic pricing const session = await this.stripe.checkout.sessions.create({ mode: 'subscription', payment_method_types: ['card'], line_items: [ { price_data: { currency: 'usd', product_data: { name: sessionData.type === 'monthly' ? `${sessionData.projectName} - Monthly Support` : `${sessionData.projectName} - Annual Support`, description: sessionData.type === 'monthly' ? `Monthly recurring donation to support ${sessionData.projectName}` : `Annual recurring donation to support ${sessionData.projectName}`, }, unit_amount: Math.round(sessionData.amount * 100), // Convert to cents recurring: { interval: sessionData.type === 'monthly' ? 'month' : 'year', }, }, quantity: 1, }, ], success_url: sessionData.successUrl, cancel_url: sessionData.cancelUrl, metadata: { projectSlug: sessionData.projectSlug, donationType: sessionData.type, amount: sessionData.amount.toString(), }, }); if (!session.url) { throw new Error('Failed to create checkout session URL'); } return { sessionId: session.id, url: session.url, }; } /** * Handle Stripe webhooks */ async handleWebhook(payload, signature, webhookSecret, options) { let event; try { event = this.stripe.webhooks.constructEvent(payload, signature, webhookSecret); } catch (err) { console.error('Webhook signature verification failed:', err); throw new Error('Invalid webhook signature'); } // Handle the event switch (event.type) { case 'customer.subscription.created': if (options.onSubscriptionCreated) { await options.onSubscriptionCreated(event.data.object); } break; case 'customer.subscription.updated': if (options.onSubscriptionUpdated) { await options.onSubscriptionUpdated(event.data.object); } break; case 'customer.subscription.deleted': if (options.onSubscriptionDeleted) { await options.onSubscriptionDeleted(event.data.object); } break; case 'payment_intent.succeeded': if (options.onPaymentSucceeded) { await options.onPaymentSucceeded(event.data.object); } break; default: console.log(`Unhandled event type: ${event.type}`); } return { received: true }; } /** * Get subscription statistics for a project (legacy method) */ async getSubscriptionStats(projectSlug) { const summary = await this.getSubscriptionSummary(projectSlug); return { monthlySubscribers: summary.monthlyCount, annualSubscribers: summary.annualCount, totalRevenue: summary.monthlyRevenue + summary.annualRevenue, }; } /** * Get detailed subscription summary with MRR calculations */ async getSubscriptionSummary(projectSlug) { // Get all active subscriptions const subscriptions = await this.stripe.subscriptions.list({ status: 'active', limit: 100, // Adjust as needed for larger projects }); let monthlyRevenue = 0; let annualRevenue = 0; let monthlyCount = 0; let annualCount = 0; // Process each subscription for (const subscription of subscriptions.data) { // Check if this subscription belongs to our project by looking at metadata const belongsToProject = subscription.metadata?.projectSlug === projectSlug; if (!belongsToProject) { // Also check by examining the subscription items for our project's products let foundProjectProduct = false; for (const item of subscription.items.data) { const price = await this.stripe.prices.retrieve(item.price.id); if (typeof price.product === 'string') { const product = await this.stripe.products.retrieve(price.product); const productIds = generateProductIds(projectSlug); if (product.id === productIds.monthly || product.id === productIds.annual) { foundProjectProduct = true; break; } } } if (!foundProjectProduct) continue; } // Calculate revenue from this subscription for (const item of subscription.items.data) { const price = await this.stripe.prices.retrieve(item.price.id); const amount = (price.unit_amount || 0) / 100; // Convert from cents const quantity = item.quantity || 1; const totalAmount = amount * quantity; if (price.recurring?.interval === 'month') { monthlyRevenue += totalAmount; monthlyCount++; } else if (price.recurring?.interval === 'year') { annualRevenue += totalAmount; annualCount++; } } } // Calculate monthly equivalent of annual subscriptions const monthlyEquivalent = annualRevenue / 12; const totalMRR = monthlyRevenue + monthlyEquivalent; return { monthlyRevenue, annualRevenue, monthlyEquivalent, totalMRR, monthlyCount, annualCount, }; } } /** * Convenience function to create a checkout session */ async function createCheckoutSession(stripeSecretKey, sessionData) { const manager = new DonationStripeManager(stripeSecretKey); return manager.createCheckoutSession(sessionData); } /** * Convenience function to handle webhooks */ async function handleDonationWebhook(request, options) { try { const payload = await request.text(); const signature = request.headers.get('stripe-signature'); if (!signature) { return new Response('Missing stripe-signature header', { status: 400 }); } const manager = new DonationStripeManager(options.stripeSecretKey); const result = await manager.handleWebhook(payload, signature, options.webhookSecret, options); return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Webhook error:', error); return new Response(JSON.stringify({ error: 'Webhook handler failed' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } } exports.DonationStripeManager = DonationStripeManager; exports.createCheckoutSession = createCheckoutSession; exports.generateProductIds = generateProductIds; exports.handleDonationWebhook = handleDonationWebhook; //# sourceMappingURL=server.cjs.map