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:
100
pledge-now-pay-later/src/lib/stripe.ts
Normal file
100
pledge-now-pay-later/src/lib/stripe.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user