Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: show subscription renewal gives in feed

The gives API only fetched checkout sessions, missing monthly
subscription renewals which create invoices instead. Now also
fetches stripe.invoices.list() for recurring payments and merges
both sources. Also tightened filter to exclude non-give checkouts
(print/mug/toll) by requiring give-specific metadata.

+95 -44
+95 -44
system/netlify/functions/gives.mjs
··· 1 - // Gives Feed, 26.01.03 1 + // Gives Feed, 26.02.08 2 2 // 🎁 Fetches recent successful gives from Stripe 3 + // Includes both one-time checkout sessions and subscription invoice renewals 3 4 // Endpoint: GET /api/gives 4 5 5 6 import Stripe from "stripe"; ··· 47 48 // Only show gives from 2025 onwards (when give page launched) 48 49 const minDate = new Date('2025-01-01').getTime() / 1000; // Unix timestamp 49 50 50 - // Fetch recent successful checkout sessions (both one-time and subscriptions) 51 + // 1. Fetch checkout sessions for one-time gives and initial subscription signups 51 52 const sessions = await stripe.checkout.sessions.list({ 52 - limit: Math.min(limit * 2, 50), // Fetch extra to filter 53 + limit: 100, // Stripe max per page 53 54 status: 'complete', 54 - expand: ['data.line_items'], 55 - created: { gte: minDate }, // Only sessions from 2025+ 55 + created: { gte: minDate }, 56 56 }); 57 57 58 - // Process sessions into a clean format 59 - // Include all completed sessions - gifts will have metadata, older ones won't 60 - const gives = sessions.data 61 - .filter(session => { 62 - // Include sessions with gift/subscription metadata OR any payment session 63 - // (to catch older donations before metadata was added) 64 - const metadata = session.metadata || {}; 65 - const hasGiftMeta = metadata.type === 'gift' || metadata.type === 'subscription'; 66 - const isPayment = session.mode === 'payment' || session.mode === 'subscription'; 67 - return hasGiftMeta || isPayment; 68 - }) 69 - .slice(0, limit) 70 - .map(session => { 71 - const metadata = session.metadata || {}; 72 - const currency = (session.currency || 'usd').toUpperCase(); 73 - const amount = session.amount_total / 100; 74 - const isRecurring = metadata.type === 'subscription'; 75 - // Note comes from custom_fields (checkout form), not metadata 76 - const customFields = session.custom_fields || []; 77 - const noteField = customFields.find(f => f.key === 'note'); 78 - const note = noteField?.text?.value || ''; 79 - 80 - // Format amount based on currency 81 - let amountDisplay; 82 - if (currency === 'DKK') { 83 - amountDisplay = `${Math.round(amount)} kr`; 84 - } else { 85 - amountDisplay = `$${amount.toFixed(amount % 1 === 0 ? 0 : 2)}`; 86 - } 58 + // Only include sessions with give-specific metadata 59 + // (excludes print/mug/news-toll sessions that also use mode:'payment') 60 + const giftSessions = sessions.data.filter(session => { 61 + const type = (session.metadata || {}).type; 62 + return type === 'gift' || type === 'subscription'; 63 + }); 87 64 88 - return { 89 - id: session.id, 90 - amount, 91 - amountDisplay, 92 - currency, 93 - isRecurring, 94 - note, 95 - createdAt: session.created * 1000, // Convert to JS timestamp 96 - // Don't expose customer info for privacy 97 - }; 65 + // Track subscription IDs from checkout sessions to avoid duplicates 66 + const checkoutSubIds = new Set( 67 + giftSessions 68 + .filter(s => s.subscription) 69 + .map(s => typeof s.subscription === 'string' ? s.subscription : s.subscription.id) 70 + ); 71 + 72 + // 2. Fetch recent paid subscription invoices (captures recurring renewals) 73 + // Subscription renewals don't create new checkout sessions — only invoices. 74 + let invoiceGives = []; 75 + try { 76 + const invoices = await stripe.invoices.list({ 77 + limit: 50, 78 + status: 'paid', 79 + created: { gte: minDate }, 98 80 }); 81 + 82 + invoiceGives = invoices.data 83 + .filter(inv => { 84 + // Only subscription invoices 85 + if (!inv.subscription) return false; 86 + const subId = typeof inv.subscription === 'string' ? inv.subscription : inv.subscription.id; 87 + // Skip the initial create invoice if we already have a checkout session for it 88 + if (inv.billing_reason === 'subscription_create' && checkoutSubIds.has(subId)) { 89 + return false; 90 + } 91 + return true; 92 + }) 93 + .map(inv => { 94 + const currency = (inv.currency || 'usd').toUpperCase(); 95 + const amount = inv.amount_paid / 100; 96 + let amountDisplay; 97 + if (currency === 'DKK') { 98 + amountDisplay = `${Math.round(amount)} kr`; 99 + } else { 100 + amountDisplay = `$${amount.toFixed(amount % 1 === 0 ? 0 : 2)}`; 101 + } 102 + return { 103 + id: inv.id, 104 + amount, 105 + amountDisplay, 106 + currency, 107 + isRecurring: true, 108 + note: '', // Renewal invoices don't carry the original checkout note 109 + createdAt: (inv.status_transitions?.paid_at || inv.created) * 1000, 110 + }; 111 + }); 112 + } catch (e) { 113 + console.log('Could not fetch subscription invoices:', e.message); 114 + } 115 + 116 + // 3. Convert checkout sessions to gives 117 + const sessionGives = giftSessions.slice(0, limit).map(session => { 118 + const metadata = session.metadata || {}; 119 + const currency = (session.currency || 'usd').toUpperCase(); 120 + const amount = session.amount_total / 100; 121 + const isRecurring = metadata.type === 'subscription'; 122 + // Note comes from custom_fields (checkout form), not metadata 123 + const customFields = session.custom_fields || []; 124 + const noteField = customFields.find(f => f.key === 'note'); 125 + const note = noteField?.text?.value || ''; 126 + 127 + // Format amount based on currency 128 + let amountDisplay; 129 + if (currency === 'DKK') { 130 + amountDisplay = `${Math.round(amount)} kr`; 131 + } else { 132 + amountDisplay = `$${amount.toFixed(amount % 1 === 0 ? 0 : 2)}`; 133 + } 134 + 135 + return { 136 + id: session.id, 137 + amount, 138 + amountDisplay, 139 + currency, 140 + isRecurring, 141 + note, 142 + createdAt: session.created * 1000, // Convert to JS timestamp 143 + }; 144 + }); 145 + 146 + // 4. Merge, sort by date (newest first), and limit 147 + const gives = [...sessionGives, ...invoiceGives] 148 + .sort((a, b) => b.createdAt - a.createdAt) 149 + .slice(0, limit); 99 150 100 151 // Calculate some stats 101 152 const totalAmount = gives.reduce((sum, g) => {