From 7fdb58604eb866204521b12e0b6ba9799e1286ce Mon Sep 17 00:00:00 2001 From: xbot Date: Sat, 4 Apr 2026 22:21:24 +0200 Subject: [PATCH 01/23] feat: replace OpenCollective with Stripe donors list - api/scripts/sync-donors.js (npm run cron): fetches Stripe checkout sessions for the donation payment link, builds donors.json - api/scripts/seed-oc-donors.js (npm run seed:oc-donors): one-time script to snapshot current OC backers into donors.json - frontend/components/DonorsList.js: replaces OpenCollectiveData, reads from /data/donors.json (static file, no API calls) - Updated donate page and confirm_signature to use DonorsList - Empty donors.json placeholder committed so frontend doesn't 404 Flow: 1. Run seed:oc-donors once to capture existing OC backers 2. Set up daily cron: STRIPE_SECRET_KEY=rk_xxx npm run cron 3. Cron merges Stripe donors with existing OC donors 4. donors.json is committed/deployed with the frontend --- api/package-lock.json | 24 ++++ api/package.json | 3 + api/scripts/seed-oc-donors.js | 99 ++++++++++++++++ api/scripts/sync-donors.js | 129 +++++++++++++++++++++ frontend/components/DonorsList.js | 49 ++++++++ frontend/pages/[slug]/confirm_signature.js | 4 +- frontend/pages/donate.js | 4 +- frontend/public/data/donors.json | 9 ++ 8 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 api/scripts/seed-oc-donors.js create mode 100644 api/scripts/sync-donors.js create mode 100644 frontend/components/DonorsList.js create mode 100644 frontend/public/data/donors.json diff --git a/api/package-lock.json b/api/package-lock.json index 1d99fd2..0b5bc43 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -29,6 +29,7 @@ "pg": "^8.5.1", "sanitize-html": "^1.23.0", "slugify": "^1.4.0", + "stripe": "^22.0.0", "url-parse": "^1.4.7" } }, @@ -5985,6 +5986,23 @@ "node": ">=0.10.0" } }, + "node_modules/stripe": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.0.0.tgz", + "integrity": "sha512-q1UgXXpSfZCmkyzZEh3vFEWT7+ajuaFGqaP9Tsi2NMtwlkigIWNr+KBIUQqtNeNEsreDKgdn+BP5HRW9JDj22Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/success-symbol": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/success-symbol/-/success-symbol-0.1.0.tgz", @@ -11557,6 +11575,12 @@ "resolved": "https://registry.npmjs.org/strip-color/-/strip-color-0.1.0.tgz", "integrity": "sha1-EG9l09PmotlAHKwOsM6LinArT3s=" }, + "stripe": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.0.0.tgz", + "integrity": "sha512-q1UgXXpSfZCmkyzZEh3vFEWT7+ajuaFGqaP9Tsi2NMtwlkigIWNr+KBIUQqtNeNEsreDKgdn+BP5HRW9JDj22Q==", + "requires": {} + }, "success-symbol": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/success-symbol/-/success-symbol-0.1.0.tgz", diff --git a/api/package.json b/api/package.json index b07faba..fe67b60 100644 --- a/api/package.json +++ b/api/package.json @@ -7,6 +7,8 @@ "scripts": { "start": "node server.js", "test": "node ace test", + "cron": "node scripts/sync-donors.js", + "seed:oc-donors": "node scripts/seed-oc-donors.js", "db:copy:prod": "./scripts/dbdump-from-heroku.sh openletter-earth" }, "keywords": [ @@ -37,6 +39,7 @@ "pg": "^8.5.1", "sanitize-html": "^1.23.0", "slugify": "^1.4.0", + "stripe": "^22.0.0", "url-parse": "^1.4.7" }, "autoload": { diff --git a/api/scripts/seed-oc-donors.js b/api/scripts/seed-oc-donors.js new file mode 100644 index 0000000..602ac68 --- /dev/null +++ b/api/scripts/seed-oc-donors.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node + +/** + * seed-oc-donors.js + * + * One-time script to fetch current OpenCollective backers and seed donors.json. + * Run once before sunsetting the OC GraphQL integration. + * + * Usage: OC_GRAPHQL_API=https://api.opencollective.com/graphql/v1 node scripts/seed-oc-donors.js + */ + +const fs = require('fs'); +const path = require('path'); + +const OUTPUT_PATH = path.join(__dirname, '../../frontend/public/data/donors.json'); + +const query = ` + query getCollectiveBackers { + Collective(slug: "openletter") { + members(role: "BACKER") { + publicMessage + member { + slug + name + } + } + currency + stats { + balance + totalAmountReceived + totalAmountSpent + backers { + all + } + } + } + } +`; + +async function main() { + const apiUrl = process.env.OC_GRAPHQL_API || 'https://api.opencollective.com/graphql/v1'; + + console.log(`Fetching OC backers from ${apiUrl}...`); + + const res = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + }); + + const json = await res.json(); + const members = json.data.Collective.members; + const stats = json.data.Collective.stats; + + const ocDonors = members + .filter((m) => m.member.name !== 'Guest') + .map((m) => ({ + name: m.member.name, + source: 'opencollective', + ocSlug: m.member.slug, + })); + + console.log(`Found ${ocDonors.length} OC backers`); + + // Load existing file if any + let existing = []; + try { + const data = JSON.parse(fs.readFileSync(OUTPUT_PATH, 'utf8')); + existing = (data.donors || []).filter((d) => d.source !== 'opencollective'); + } catch {} + + const allDonors = [...existing, ...ocDonors]; + + const output = { + donors: allDonors, + stats: { + total: allDonors.length, + stripe: allDonors.filter((d) => d.source === 'stripe').length, + opencollective: allDonors.filter((d) => d.source === 'opencollective').length, + }, + ocLegacy: { + currency: json.data.Collective.currency, + balance: stats.balance, + totalReceived: stats.totalAmountReceived, + totalSpent: stats.totalAmountSpent, + }, + lastUpdated: new Date().toISOString(), + }; + + fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true }); + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(output, null, 2)); + + console.log(`Wrote ${allDonors.length} donors to ${OUTPUT_PATH}`); +} + +main().catch((err) => { + console.error('seed-oc-donors failed:', err); + process.exit(1); +}); diff --git a/api/scripts/sync-donors.js b/api/scripts/sync-donors.js new file mode 100644 index 0000000..c3bb17c --- /dev/null +++ b/api/scripts/sync-donors.js @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +/** + * sync-donors.js + * + * Fetches donors from Stripe (payment link checkout sessions) and merges + * with existing OpenCollective donors into a single donors.json file. + * + * Usage: STRIPE_SECRET_KEY=rk_xxx node scripts/sync-donors.js + * Or: npm run cron + * + * Output: ../frontend/public/data/donors.json + */ + +const fs = require('fs'); +const path = require('path'); + +const PAYMENT_LINK_ID = 'plink_1TGev1FAhaWeDyowQqEek3mT'; +const OUTPUT_PATH = path.join(__dirname, '../../frontend/public/data/donors.json'); + +async function fetchStripeDonors() { + const secretKey = process.env.STRIPE_SECRET_KEY; + if (!secretKey) { + console.error('Missing STRIPE_SECRET_KEY env var'); + process.exit(1); + } + + const Stripe = require('stripe'); + const stripe = new Stripe(secretKey); + + const donors = []; + let hasMore = true; + let startingAfter = null; + + console.log(`Fetching checkout sessions for payment link ${PAYMENT_LINK_ID}...`); + + while (hasMore) { + const params = { + payment_link: PAYMENT_LINK_ID, + limit: 100, + expand: ['data.customer_details'], + }; + if (startingAfter) params.starting_after = startingAfter; + + const sessions = await stripe.checkout.sessions.list(params); + + for (const session of sessions.data) { + if (session.payment_status !== 'paid') continue; + + const name = + session.customer_details?.name || + session.metadata?.name || + null; + + if (!name) continue; + + donors.push({ + name, + amount: session.amount_total ? session.amount_total / 100 : null, + currency: session.currency || 'eur', + date: new Date(session.created * 1000).toISOString().split('T')[0], + source: 'stripe', + }); + } + + hasMore = sessions.has_more; + if (sessions.data.length > 0) { + startingAfter = sessions.data[sessions.data.length - 1].id; + } + } + + console.log(`Found ${donors.length} Stripe donors`); + return donors; +} + +function loadExistingDonors() { + try { + const data = JSON.parse(fs.readFileSync(OUTPUT_PATH, 'utf8')); + return data.donors || []; + } catch { + return []; + } +} + +function deduplicateDonors(donors) { + const seen = new Map(); + for (const donor of donors) { + const key = `${donor.name.toLowerCase()}:${donor.source}:${donor.date || ''}`; + if (!seen.has(key)) { + seen.set(key, donor); + } + } + return Array.from(seen.values()); +} + +async function main() { + const stripeDonors = await fetchStripeDonors(); + + // Keep existing OC donors from the file + const existing = loadExistingDonors(); + const ocDonors = existing.filter((d) => d.source === 'opencollective'); + + const allDonors = deduplicateDonors([...stripeDonors, ...ocDonors]); + + // Sort by date (newest first) + allDonors.sort((a, b) => (b.date || '').localeCompare(a.date || '')); + + const output = { + donors: allDonors, + stats: { + total: allDonors.length, + stripe: allDonors.filter((d) => d.source === 'stripe').length, + opencollective: allDonors.filter((d) => d.source === 'opencollective').length, + }, + lastUpdated: new Date().toISOString(), + }; + + // Ensure output directory exists + fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true }); + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(output, null, 2)); + + console.log(`Wrote ${allDonors.length} donors to ${OUTPUT_PATH}`); + console.log(` Stripe: ${output.stats.stripe}, OpenCollective: ${output.stats.opencollective}`); +} + +main().catch((err) => { + console.error('sync-donors failed:', err); + process.exit(1); +}); diff --git a/frontend/components/DonorsList.js b/frontend/components/DonorsList.js new file mode 100644 index 0000000..36be439 --- /dev/null +++ b/frontend/components/DonorsList.js @@ -0,0 +1,49 @@ +import useSWR from 'swr'; +import { withIntl } from '../lib/i18n'; + +const fetcher = (url) => fetch(url).then((res) => res.json()); + +function DonorsList({ t }) { + const { data, error } = useSWR('/data/donors.json', fetcher); + + if (error) return null; + if (!data) return
Loading...
; + + const donors = data.donors || []; + if (donors.length === 0) return null; + + return ( +
+

Thank you to all our contributors πŸ™

+ + {data.stats && ( +

+ {data.stats.total} contributors +

+ )} +
+ ); +} + +export default withIntl(DonorsList); diff --git a/frontend/pages/[slug]/confirm_signature.js b/frontend/pages/[slug]/confirm_signature.js index 85090ec..c64ab95 100644 --- a/frontend/pages/[slug]/confirm_signature.js +++ b/frontend/pages/[slug]/confirm_signature.js @@ -4,7 +4,7 @@ import fetch from 'node-fetch'; import Notification from '../../components/Notification'; import Router, { withRouter } from 'next/router'; import { withIntl } from '../../lib/i18n'; -import OpenCollectiveData from '../../components/OpenCollectiveData'; +import DonorsList from '../../components/DonorsList'; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -84,7 +84,7 @@ class ConfirmSignaturePage extends Component { {t('notification.signed.donate.button')} - + )} {!status && ( diff --git a/frontend/pages/donate.js b/frontend/pages/donate.js index 9884a27..1c13a4e 100644 --- a/frontend/pages/donate.js +++ b/frontend/pages/donate.js @@ -1,7 +1,7 @@ import React from 'react'; import Head from 'next/head'; import Footer from '../components/Footer'; -import OpenCollectiveData from '../components/OpenCollectiveData'; +import DonorsList from '../components/DonorsList'; import { withIntl } from '../lib/i18n'; function DonatePage({ t }) { @@ -28,7 +28,7 @@ function DonatePage({ t }) { {t('notification.signed.donate.button')} - +