Stripe integration: charity connects their own Stripe account

Model: PNPL never touches the money. Each charity connects their own
Stripe account by pasting their API key in Settings. When a donor
chooses card payment, they're redirected to Stripe Checkout. The money
lands in the charity's Stripe balance.

## Schema
- Organization.stripeSecretKey (new column)
- Organization.stripeWebhookSecret (new column)

## New/rewritten files
- src/lib/stripe.ts — getStripeForOrg(secretKey), per-org client
- src/app/api/stripe/checkout/route.ts — uses org's key, not env var
- src/app/api/stripe/webhook/route.ts — tries all org webhook secrets
- src/app/p/[token]/steps/card-payment-step.tsx — redirect to Stripe
  Checkout (no fake card form — Stripe handles PCI)

## Settings page
- New 'Card payments' section between Bank and Charity
- Instructions: how to get your Stripe API key
- Webhook setup in collapsed <details> (optional, for auto-confirm)
- 'Card payments live' green banner when connected
- Readiness bar shows Stripe status (5 columns now)

## Pledge flow
- PaymentStep shows card option ONLY if org has Stripe configured
- hasStripe flag passed from /api/qr/[token] → PaymentStep
- Secret key never exposed to frontend (only boolean hasStripe)

## How it works
1. Charity pastes sk_live_... in Settings → Save
2. Donor opens pledge link → sees 'Bank Transfer', 'Direct Debit', 'Card'
3. Donor picks card → enters name + email → redirects to Stripe Checkout
4. Stripe processes payment → money in charity's Stripe balance
5. (Optional) Webhook auto-confirms pledge as paid

Payment options:
- Bank Transfer: zero fees (default, always available)
- Direct Debit via GoCardless: 1% + 20p (if org configured)
- Card via Stripe: standard Stripe fees (if org configured)
This commit is contained in:
2026-03-04 22:46:08 +08:00
parent 62be460643
commit 3b46222118
27 changed files with 1292 additions and 151 deletions

View File

@@ -0,0 +1,100 @@
import Stripe from "stripe"
/**
* Stripe integration — per-org.
*
* Each charity connects their OWN Stripe account.
* PNPL never touches the money. The charity's Stripe processes the payment,
* the money lands in the charity's Stripe balance.
*
* The secret key is stored in Organization.stripeSecretKey.
*/
/**
* Create a Stripe client using the org's own secret key.
* Returns null if no key is configured.
*/
export function getStripeForOrg(secretKey: string | null | undefined): Stripe | null {
if (!secretKey || secretKey.trim() === "") return null
return new Stripe(secretKey, {
apiVersion: "2025-01-27.acacia" as Stripe.LatestApiVersion,
typescript: true,
})
}
/**
* Create a Stripe Checkout Session using the org's Stripe account.
* Returns the checkout URL to redirect the donor to.
*/
export async function createCheckoutSession(opts: {
stripeSecretKey: string
amountPence: number
currency: string
pledgeId: string
reference: string
eventName: string
organizationName: string
donorEmail?: string
successUrl: string
cancelUrl: string
}): Promise<{ sessionId: string; checkoutUrl: string } | null> {
const stripe = getStripeForOrg(opts.stripeSecretKey)
if (!stripe) return null
try {
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
line_items: [
{
price_data: {
currency: opts.currency.toLowerCase(),
unit_amount: opts.amountPence,
product_data: {
name: `Donation — ${opts.eventName}`,
description: `Ref: ${opts.reference} to ${opts.organizationName}`,
},
},
quantity: 1,
},
],
customer_email: opts.donorEmail || undefined,
metadata: {
pledge_id: opts.pledgeId,
reference: opts.reference,
},
success_url: opts.successUrl,
cancel_url: opts.cancelUrl,
})
return {
sessionId: session.id,
checkoutUrl: session.url!,
}
} catch (error) {
console.error("Stripe checkout error:", error)
return null
}
}
/**
* Verify a Stripe webhook signature using the org's webhook secret.
*/
export function constructWebhookEvent(
body: string | Buffer,
signature: string,
webhookSecret: string
): Stripe.Event | null {
// We need a Stripe instance just for webhook verification — use a dummy key
// The webhook secret is what matters for signature verification
try {
const stripe = new Stripe("sk_dummy_for_webhook_verify", {
apiVersion: "2025-01-27.acacia" as Stripe.LatestApiVersion,
})
return stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch (error) {
console.error("Stripe webhook signature verification failed:", error)
return null
}
}