diff --git a/.env.example b/.env.example index 3b5aa11..6d48d56 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,7 @@ -# Stripe Payment Integration -STRIPE_SECRET_KEY=sk_live_xxx -STRIPE_WEBHOOK_SECRET=whsec_xxx -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx +# Mollie Payment Integration +MOLLIE_API_KEY=live_xxx -# Public URL (used for Stripe redirect URLs) +# Public URL (used for payment redirect URLs) NEXT_PUBLIC_BASE_URL=https://cryptocommonsgather.ing # Google Sheets Integration diff --git a/app/api/create-checkout-session/route.ts b/app/api/create-checkout-session/route.ts index 0d9e952..82cd6e5 100644 --- a/app/api/create-checkout-session/route.ts +++ b/app/api/create-checkout-session/route.ts @@ -1,138 +1,86 @@ import { type NextRequest, NextResponse } from "next/server" -import Stripe from "stripe" +import createMollieClient from "@mollie/api-client" // Lazy initialization to avoid build-time errors -let stripe: Stripe | null = null +let mollieClient: ReturnType | null = null -function getStripe() { - if (!stripe) { - stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2024-12-18.acacia", - }) +function getMollie() { + if (!mollieClient) { + mollieClient = createMollieClient({ apiKey: process.env.MOLLIE_API_KEY! }) } - return stripe + return mollieClient } -// Dynamic pricing configuration (in EUR cents) -const TICKET_PRICE_CENTS = 8000 // €80 early bird +// Dynamic pricing configuration (in EUR) +const TICKET_PRICE = 80 // €80 early bird +const DORM_PRICE = 235.2 // €235.20 (€39.20/night x 6) +const DOUBLE_PRICE = 301.2 // €301.20 (€50.20/night x 6) +const FOOD_PRICE = 135 // €135 (6 days) -// Public base URL (needed because request.nextUrl.origin returns internal Docker address) +// Public base URL const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "https://cryptocommonsgather.ing" export async function POST(request: NextRequest) { try { const formData = await request.formData() - const paymentMethod = formData.get("paymentMethod") as string const registrationDataStr = formData.get("registrationData") as string const includeAccommodation = formData.get("includeAccommodation") === "true" - const accommodationType = formData.get("accommodationType") as string || "dorm" + const accommodationType = (formData.get("accommodationType") as string) || "dorm" const includeFood = formData.get("includeFood") === "true" const registrationData = registrationDataStr ? JSON.parse(registrationDataStr) : null - // Accommodation pricing (in EUR cents) - const DORM_PRICE_CENTS = 23520 // €235.20 (€39.20/night x 6) - const DOUBLE_PRICE_CENTS = 30120 // €301.20 (€50.20/night x 6) - const FOOD_PRICE_CENTS = 13500 // €135 (6 days) - - // Build line items dynamically - const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = [ - { - price_data: { - currency: "eur", - product_data: { - name: "CCG 2026 Ticket", - description: "Crypto Commons Gathering 2026 - August 16-22, Austria", - }, - unit_amount: TICKET_PRICE_CENTS, - }, - quantity: 1, - }, - ] + // Calculate total + let total = TICKET_PRICE + const descriptionParts = ["CCG 2026 Ticket (€80)"] if (includeAccommodation) { const isDorm = accommodationType === "dorm" - lineItems.push({ - price_data: { - currency: "eur", - product_data: { - name: isDorm ? "Accommodation - Dorm (6 nights)" : "Accommodation - Double Room (6 nights)", - description: `Commons Hub, ${isDorm ? "dorm bed" : "double room"} — ${isDorm ? "€39.20" : "€50.20"}/night`, - }, - unit_amount: isDorm ? DORM_PRICE_CENTS : DOUBLE_PRICE_CENTS, - }, - quantity: 1, - }) + const accomPrice = isDorm ? DORM_PRICE : DOUBLE_PRICE + total += accomPrice + descriptionParts.push(`${isDorm ? "Dorm" : "Double Room"} (€${accomPrice.toFixed(2)})`) } if (includeFood) { - lineItems.push({ - price_data: { - currency: "eur", - product_data: { - name: "Food Package (6 days)", - description: "Breakfast, catered lunches & dinners, coffee & tea", - }, - unit_amount: FOOD_PRICE_CENTS, - }, - quantity: 1, - }) + total += FOOD_PRICE + descriptionParts.push(`Food Package (€${FOOD_PRICE})`) } - let paymentMethodTypes: Stripe.Checkout.SessionCreateParams.PaymentMethodType[] = ["card"] - - if (paymentMethod === "sepa_debit") { - paymentMethodTypes = ["sepa_debit"] - } else if (paymentMethod === "crypto") { - paymentMethodTypes = ["customer_balance"] + // Build metadata for webhook + const metadata: Record = {} + if (registrationData) { + metadata.name = registrationData.name || "" + metadata.contact = registrationData.contact || "" + metadata.contributions = (registrationData.contributions || "").substring(0, 500) + metadata.expectations = (registrationData.expectations || "").substring(0, 500) + metadata.howHeard = registrationData.howHeard || "" + metadata.dietary = + (registrationData.dietary || []).join(", ") + + (registrationData.dietaryOther ? `, ${registrationData.dietaryOther}` : "") + metadata.crewConsent = registrationData.crewConsent || "" + metadata.accommodation = includeAccommodation ? accommodationType : "none" + metadata.food = includeFood ? "yes" : "no" } - const session = await getStripe().checkout.sessions.create({ - payment_method_types: paymentMethodTypes, - line_items: lineItems, - mode: "payment", - success_url: `${BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${BASE_URL}/register`, - metadata: registrationData - ? { - name: registrationData.name, - contact: registrationData.contact, - contributions: registrationData.contributions.substring(0, 500), - expectations: registrationData.expectations.substring(0, 500), - howHeard: registrationData.howHeard || "", - dietary: - registrationData.dietary.join(", ") + - (registrationData.dietaryOther ? `, ${registrationData.dietaryOther}` : ""), - crewConsent: registrationData.crewConsent, - accommodation: includeAccommodation ? accommodationType : "none", - food: includeFood ? "yes" : "no", - } - : {}, - ...(paymentMethod === "crypto" && { - payment_method_options: { - customer_balance: { - funding_type: "bank_transfer", - bank_transfer: { - type: "us_bank_account", - }, - }, - }, - }), - allow_promotion_codes: true, - billing_address_collection: "required", - phone_number_collection: { - enabled: true, + const payment = await getMollie().payments.create({ + amount: { + value: total.toFixed(2), + currency: "EUR", }, + description: `CCG 2026 Registration — ${descriptionParts.join(" + ")}`, + redirectUrl: `${BASE_URL}/success`, + webhookUrl: `${BASE_URL}/api/webhook`, + metadata, }) - // Use 303 redirect for POST requests (tells browser to follow with GET) + // Redirect to Mollie checkout return new Response(null, { status: 303, - headers: { Location: session.url! }, + headers: { Location: payment.getCheckoutUrl()! }, }) } catch (err) { - console.error("Error creating checkout session:", err) - return NextResponse.json({ error: "Error creating checkout session" }, { status: 500 }) + console.error("Error creating Mollie payment:", err) + return NextResponse.json({ error: "Error creating payment" }, { status: 500 }) } } diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts index fa44d96..e8b4b82 100644 --- a/app/api/webhook/route.ts +++ b/app/api/webhook/route.ts @@ -1,121 +1,93 @@ import { type NextRequest, NextResponse } from "next/server" -import Stripe from "stripe" +import createMollieClient from "@mollie/api-client" import { updatePaymentStatus } from "@/lib/google-sheets" import { sendPaymentConfirmation } from "@/lib/email" import { addToListmonk } from "@/lib/listmonk" -// Lazy initialization to avoid build-time errors -let stripe: Stripe | null = null +// Lazy initialization +let mollieClient: ReturnType | null = null -function getStripe() { - if (!stripe) { - stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2024-12-18.acacia", - }) +function getMollie() { + if (!mollieClient) { + mollieClient = createMollieClient({ apiKey: process.env.MOLLIE_API_KEY! }) } - return stripe -} - -function getWebhookSecret() { - return process.env.STRIPE_WEBHOOK_SECRET! + return mollieClient } export async function POST(request: NextRequest) { try { - const body = await request.text() - const signature = request.headers.get("stripe-signature")! + // Mollie sends payment ID in the body as form data + const formData = await request.formData() + const paymentId = formData.get("id") as string - let event: Stripe.Event - - try { - event = getStripe().webhooks.constructEvent(body, signature, getWebhookSecret()) - } catch (err) { - console.error("[Webhook] Signature verification failed:", err) - return NextResponse.json({ error: "Invalid signature" }, { status: 400 }) + if (!paymentId) { + console.error("[Webhook] No payment ID received") + return NextResponse.json({ error: "Missing payment ID" }, { status: 400 }) } - // Handle the event - switch (event.type) { - case "checkout.session.completed": { - const session = event.data.object as Stripe.Checkout.Session - console.log("[Webhook] Payment successful:", session.id) + // Fetch the full payment from Mollie API (this is how you verify — no signature needed) + const payment = await getMollie().payments.get(paymentId) + const metadata = (payment.metadata || {}) as Record - // Extract registration data from metadata - const metadata = session.metadata || {} - const customerEmail = session.customer_details?.email || "" + console.log(`[Webhook] Payment ${paymentId} status: ${payment.status}`) - // Update Google Sheet with payment confirmation - const updated = await updatePaymentStatus({ + if (payment.status === "paid") { + const customerEmail = payment.billingAddress?.email || "" + const amountPaid = `€${payment.amount.value}` + + // Update Google Sheet + const updated = await updatePaymentStatus({ + name: metadata.name || "", + email: customerEmail, + paymentSessionId: paymentId, + paymentStatus: "Paid", + paymentMethod: payment.method || "unknown", + amountPaid, + paymentDate: new Date().toISOString(), + }) + + if (updated) { + console.log(`[Webhook] Google Sheet updated for ${metadata.name}`) + } else { + console.error(`[Webhook] Failed to update Google Sheet for ${metadata.name}`) + } + + // Send confirmation email + if (customerEmail) { + await sendPaymentConfirmation({ name: metadata.name || "", email: customerEmail, - stripeSessionId: session.id, - paymentStatus: "Paid", - paymentMethod: session.payment_method_types?.[0] || "unknown", - amountPaid: session.amount_total - ? `€${(session.amount_total / 100).toFixed(2)}` - : "", - paymentDate: new Date().toISOString(), + amountPaid, + paymentMethod: payment.method || "card", + contributions: metadata.contributions || "", + dietary: metadata.dietary || "", }) - if (updated) { - console.log(`[Webhook] Google Sheet updated for ${metadata.name}`) - } else { - console.error(`[Webhook] Failed to update Google Sheet for ${metadata.name}`) - } - - // Send payment confirmation email - if (customerEmail) { - await sendPaymentConfirmation({ - name: metadata.name || "", - email: customerEmail, - amountPaid: session.amount_total - ? `€${(session.amount_total / 100).toFixed(2)}` - : "", - paymentMethod: session.payment_method_types?.[0] || "card", - contributions: metadata.contributions || "", - dietary: metadata.dietary || "", - }) - - // Add to Listmonk newsletter - addToListmonk({ - email: customerEmail, - name: metadata.name || "", - attribs: { - contact: metadata.contact, - contributions: metadata.contributions, - expectations: metadata.expectations, - }, - }).catch((err) => console.error("[Webhook] Listmonk sync failed:", err)) - } - - break + // Add to Listmonk newsletter + addToListmonk({ + email: customerEmail, + name: metadata.name || "", + attribs: { + contact: metadata.contact, + contributions: metadata.contributions, + expectations: metadata.expectations, + }, + }).catch((err) => console.error("[Webhook] Listmonk sync failed:", err)) } - case "payment_intent.succeeded": { - const paymentIntent = event.data.object as Stripe.PaymentIntent - console.log("[Webhook] PaymentIntent successful:", paymentIntent.id) - break - } - case "payment_intent.payment_failed": { - const paymentIntent = event.data.object as Stripe.PaymentIntent - console.error("[Webhook] Payment failed:", paymentIntent.id) + } else if (payment.status === "failed" || payment.status === "canceled" || payment.status === "expired") { + console.log(`[Webhook] Payment ${payment.status}: ${paymentId}`) - // Optionally update sheet to mark as failed - const failedMetadata = paymentIntent.metadata || {} - if (failedMetadata.name) { - await updatePaymentStatus({ - name: failedMetadata.name, - stripeSessionId: paymentIntent.id, - paymentStatus: "Failed", - paymentDate: new Date().toISOString(), - }) - } - - break + if (metadata.name) { + await updatePaymentStatus({ + name: metadata.name, + paymentSessionId: paymentId, + paymentStatus: "Failed", + paymentDate: new Date().toISOString(), + }) } - default: - console.log("[Webhook] Unhandled event type:", event.type) } + // Mollie expects 200 OK return NextResponse.json({ received: true }) } catch (err) { console.error("[Webhook] Error:", err) diff --git a/app/register/page.tsx b/app/register/page.tsx index 578f506..656d898 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -258,34 +258,10 @@ export default function RegisterPage() {
-
- - -
- - -
- -
- - -
- -
- - -
-
-
+

+ You'll be redirected to Mollie's secure checkout where you can pay by credit card, + SEPA bank transfer, iDEAL, PayPal, or other methods. +