From 87f3fb95c2aeadf64c1b0c5c6549d95920ef1855 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 23 Feb 2026 20:36:41 -0800 Subject: [PATCH] feat: complete Mollie payment integration Add payment routes, checkout redirect, return page, DB schema updates, and environment configuration for Mollie payment processing. Co-Authored-By: Claude Opus 4.6 --- .env.example | 10 ++ apply.html | 7 + db/migration-002-mollie.sql | 11 ++ db/schema.sql | 8 ++ docker-compose.yml | 6 + package-lock.json | 119 +++++++++++++++++ package.json | 3 +- payment-return.html | 259 ++++++++++++++++++++++++++++++++++++ server.js | 3 + 9 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 db/migration-002-mollie.sql create mode 100644 payment-return.html diff --git a/.env.example b/.env.example index 4927e9b..95d9b59 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,16 @@ GOOGLE_SERVICE_ACCOUNT=your_service_account_json_here GOOGLE_SHEET_ID=your_sheet_id_here GOOGLE_SHEET_NAME=Waitlist +# ============================================ +# Mollie Payment Integration +# ============================================ +# Mollie API key (test or live) +# Test keys start with 'test_', live keys start with 'live_' +MOLLIE_API_KEY=test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Public base URL for redirects and webhooks +BASE_URL=https://votc.jeffemmett.com + # ============================================ # AI Gateway Configuration for Game Chat # ============================================ diff --git a/apply.html b/apply.html index bdc150f..7794ef1 100644 --- a/apply.html +++ b/apply.html @@ -940,6 +940,13 @@ const result = await response.json(); if (response.ok && result.success) { + // Redirect to Mollie checkout if payment URL was returned + if (result.checkoutUrl) { + window.location.href = result.checkoutUrl; + return; + } + + // No payment needed - show success directly document.getElementById('confirm-email').textContent = data.email; document.querySelectorAll('.form-section').forEach(s => s.classList.remove('active')); document.querySelector('.form-section[data-step="success"]').style.display = 'block'; diff --git a/db/migration-002-mollie.sql b/db/migration-002-mollie.sql new file mode 100644 index 0000000..1665bb6 --- /dev/null +++ b/db/migration-002-mollie.sql @@ -0,0 +1,11 @@ +-- Migration 002: Add Mollie payment fields to applications table +-- Run this against existing databases to add payment support + +ALTER TABLE applications + ADD COLUMN IF NOT EXISTS mollie_payment_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS payment_status VARCHAR(50) DEFAULT 'unpaid', + ADD COLUMN IF NOT EXISTS payment_amount DECIMAL(10, 2), + ADD COLUMN IF NOT EXISTS payment_paid_at TIMESTAMP WITH TIME ZONE; + +CREATE INDEX IF NOT EXISTS idx_applications_mollie_id ON applications(mollie_payment_id); +CREATE INDEX IF NOT EXISTS idx_applications_payment_status ON applications(payment_status); diff --git a/db/schema.sql b/db/schema.sql index d9d4d3b..744b4e4 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -85,6 +85,12 @@ CREATE TABLE IF NOT EXISTS applications ( scholarship_reason TEXT, contribution_amount VARCHAR(50), -- sliding scale selection + -- Payment (Mollie) + mollie_payment_id VARCHAR(255), + payment_status VARCHAR(50) DEFAULT 'unpaid', -- unpaid, pending, open, paid, failed, canceled, expired + payment_amount DECIMAL(10, 2), + payment_paid_at TIMESTAMP WITH TIME ZONE, + -- Admin notes admin_notes TEXT, @@ -98,6 +104,8 @@ CREATE TABLE IF NOT EXISTS applications ( CREATE INDEX idx_applications_email ON applications(email); CREATE INDEX idx_applications_status ON applications(status); CREATE INDEX idx_applications_submitted ON applications(submitted_at); +CREATE INDEX idx_applications_mollie_id ON applications(mollie_payment_id); +CREATE INDEX idx_applications_payment_status ON applications(payment_status); -- Email log table (track all sent emails) CREATE TABLE IF NOT EXISTS email_log ( diff --git a/docker-compose.yml b/docker-compose.yml index 5337e6c..4534e4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,12 @@ services: - ADMIN_API_KEY=${ADMIN_API_KEY} - ADMIN_EMAILS=${ADMIN_EMAILS:-jeff@jeffemmett.com} - NODE_ENV=production + - GOOGLE_SHEET_ID=1uZy21IjIwAES92ki6K33CtiTfEzGoUlxQ8jk3IzA0qI + - GOOGLE_SERVICE_ACCOUNT_FILE=/run/secrets/google-service-account.json + - MOLLIE_API_KEY=${MOLLIE_API_KEY} + - BASE_URL=https://votc.jeffemmett.com + volumes: + - ./google-service-account.json:/run/secrets/google-service-account.json:ro depends_on: votc-db: condition: service_healthy diff --git a/package-lock.json b/package-lock.json index f991d20..edc3f12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@ai-sdk/anthropic": "^3.0.0", "@ai-sdk/mistral": "^3.0.0", + "@mollie/api-client": "^4.4.0", "@octokit/rest": "^22.0.1", "ai": "^6.0.1", "express": "^4.21.0", @@ -92,6 +93,20 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@mollie/api-client": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@mollie/api-client/-/api-client-4.4.0.tgz", + "integrity": "sha512-V2OKBGS4TUKpbARV4JSjjtXqfRqfTQ5KCKGudEFEOEh/yQxrM2zXhkoq2gIbA6nGIVPh5nVaAzqLhp4C+e4aMQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/node-fetch": "^2.6.13", + "node-fetch": "^2.7.0", + "ruply": "^1.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@octokit/auth-token": { "version": "6.0.0", "license": "MIT", @@ -233,6 +248,25 @@ "version": "1.1.0", "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@vercel/oidc": { "version": "3.1.0", "license": "Apache-2.0", @@ -278,6 +312,12 @@ "version": "1.1.1", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "funding": [ @@ -365,6 +405,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "license": "MIT", @@ -400,6 +452,15 @@ "ms": "2.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "license": "MIT", @@ -480,6 +541,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "license": "MIT" @@ -576,6 +652,22 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "license": "MIT", @@ -735,6 +827,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "license": "MIT", @@ -1124,6 +1231,12 @@ "node": ">= 0.8" } }, + "node_modules/ruply": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ruply/-/ruply-1.0.1.tgz", + "integrity": "sha512-p39LnaaJyuucPGlgaB0KiyifpcuOkn24+Hq5y0ejAD/LlH+mRAbkHn2tckCLgHir+S+nis1WYG+TYEC4zHX0WQ==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "funding": [ @@ -1289,6 +1402,12 @@ "node": ">= 0.6" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, "node_modules/universal-user-agent": { "version": "7.0.3", "license": "ISC" diff --git a/package.json b/package.json index de659be..5cf7c9c 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,12 @@ "start": "node server.js" }, "dependencies": { - "express": "^4.21.0", "@ai-sdk/anthropic": "^3.0.0", "@ai-sdk/mistral": "^3.0.0", + "@mollie/api-client": "^4.4.0", "@octokit/rest": "^22.0.1", "ai": "^6.0.1", + "express": "^4.21.0", "googleapis": "^126.0.1", "nodemailer": "^6.9.0", "pg": "^8.13.0" diff --git a/payment-return.html b/payment-return.html new file mode 100644 index 0000000..581cc56 --- /dev/null +++ b/payment-return.html @@ -0,0 +1,259 @@ + + + + + + Payment Status - Valley of the Commons + + + + + +
+ +
+ +
+
+
+ + + + +
+

Checking payment status...

+

Please wait while we confirm your payment.

+ + +
+
+ + + + + + diff --git a/server.js b/server.js index 358b694..35ebcae 100644 --- a/server.js +++ b/server.js @@ -24,6 +24,7 @@ const waitlistHandler = require('./api/waitlist-db'); const applicationHandler = require('./api/application'); const gameChatHandler = require('./api/game-chat'); const shareToGithubHandler = require('./api/share-to-github'); +const { handleWebhook, getPaymentStatus } = require('./api/mollie'); // Adapter to convert Vercel handler to Express const vercelToExpress = (handler) => async (req, res) => { @@ -41,6 +42,8 @@ app.all('/api/waitlist', vercelToExpress(waitlistHandler)); app.all('/api/application', vercelToExpress(applicationHandler)); app.all('/api/game-chat', vercelToExpress(gameChatHandler)); app.all('/api/share-to-github', vercelToExpress(shareToGithubHandler)); +app.post('/api/mollie/webhook', vercelToExpress(handleWebhook)); +app.all('/api/mollie/status', vercelToExpress(getPaymentStatus)); // Static files app.use(express.static(path.join(__dirname), {