From 3b462221183a92a6d9d4346e2fb8b91f7ccd3655 Mon Sep 17 00:00:00 2001 From: Omair Saleh Date: Wed, 4 Mar 2026 22:46:08 +0800 Subject: [PATCH] Stripe integration: charity connects their own Stripe account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
(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) --- pledge-now-pay-later/package-lock.json | 28 +++ pledge-now-pay-later/package.json | 2 + pledge-now-pay-later/prisma/schema.prisma | 2 + .../src/app/api/qr/[token]/route.ts | 8 +- .../src/app/api/settings/route.ts | 4 +- .../src/app/api/stripe/checkout/route.ts | 129 ++++++++++++ .../src/app/api/stripe/webhook/route.ts | 118 +++++++++++ .../src/app/dashboard/settings/page.tsx | 97 +++++++++- .../src/app/p/[token]/page.tsx | 13 +- .../app/p/[token]/steps/card-payment-step.tsx | 165 ++++++++++++++++ .../app/p/[token]/steps/confirmation-step.tsx | 4 +- .../src/app/p/[token]/steps/payment-step.tsx | 21 +- .../src/app/p/success/page.tsx | 6 +- pledge-now-pay-later/src/app/page.tsx | 7 + pledge-now-pay-later/src/app/privacy/page.tsx | 1 + pledge-now-pay-later/src/app/terms/page.tsx | 2 +- pledge-now-pay-later/src/lib/stripe.ts | 100 ++++++++++ temp_files/v3/AppealResource.php | 45 +---- temp_files/v3/CustomerResource.php | 53 +---- temp_files/v3/DonationResource.php | 37 +--- temp_files/v4/AdminPanelProvider.php | 88 +++++++++ temp_files/v4/ListAppeals.php | 86 ++++++++ temp_files/v4/ListApprovalQueues.php | 50 +++++ temp_files/v4/ListCustomers.php | 62 ++++++ temp_files/v4/ListDonations.php | 79 ++++++++ .../v4/ListScheduledGivingDonations.php | 53 +++++ temp_files/v4/nav_changes.py | 183 ++++++++++++++++++ 27 files changed, 1292 insertions(+), 151 deletions(-) create mode 100644 pledge-now-pay-later/src/app/api/stripe/checkout/route.ts create mode 100644 pledge-now-pay-later/src/app/api/stripe/webhook/route.ts create mode 100644 pledge-now-pay-later/src/app/p/[token]/steps/card-payment-step.tsx create mode 100644 pledge-now-pay-later/src/lib/stripe.ts create mode 100644 temp_files/v4/AdminPanelProvider.php create mode 100644 temp_files/v4/ListAppeals.php create mode 100644 temp_files/v4/ListApprovalQueues.php create mode 100644 temp_files/v4/ListCustomers.php create mode 100644 temp_files/v4/ListDonations.php create mode 100644 temp_files/v4/ListScheduledGivingDonations.php create mode 100644 temp_files/v4/nav_changes.py diff --git a/pledge-now-pay-later/package-lock.json b/pledge-now-pay-later/package-lock.json index 043ae42..0a06b1f 100644 --- a/pledge-now-pay-later/package-lock.json +++ b/pledge-now-pay-later/package-lock.json @@ -11,6 +11,7 @@ "@auth/prisma-adapter": "^2.11.1", "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", + "@stripe/stripe-js": "^8.9.0", "@types/bcryptjs": "^2.4.6", "@types/qrcode": "^1.5.6", "bcryptjs": "^3.0.3", @@ -28,6 +29,7 @@ "react": "^18", "react-dom": "^18", "sharp": "^0.34.5", + "stripe": "^20.4.0", "tailwind-merge": "^3.5.0", "zod": "^4.3.6" }, @@ -1792,6 +1794,15 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@stripe/stripe-js": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.9.0.tgz", + "integrity": "sha512-OJkXvUI5GAc56QdiSRimQDvWYEqn475J+oj8RzRtFTCPtkJNO2TWW619oDY+nn1ExR+2tCVTQuRQBbR4dRugww==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -7781,6 +7792,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.0.tgz", + "integrity": "sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/styled-jsx": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", diff --git a/pledge-now-pay-later/package.json b/pledge-now-pay-later/package.json index af7d5f8..985e6ad 100644 --- a/pledge-now-pay-later/package.json +++ b/pledge-now-pay-later/package.json @@ -12,6 +12,7 @@ "@auth/prisma-adapter": "^2.11.1", "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", + "@stripe/stripe-js": "^8.9.0", "@types/bcryptjs": "^2.4.6", "@types/qrcode": "^1.5.6", "bcryptjs": "^3.0.3", @@ -29,6 +30,7 @@ "react": "^18", "react-dom": "^18", "sharp": "^0.34.5", + "stripe": "^20.4.0", "tailwind-merge": "^3.5.0", "zod": "^4.3.6" }, diff --git a/pledge-now-pay-later/prisma/schema.prisma b/pledge-now-pay-later/prisma/schema.prisma index 97ed657..81e5f01 100644 --- a/pledge-now-pay-later/prisma/schema.prisma +++ b/pledge-now-pay-later/prisma/schema.prisma @@ -23,6 +23,8 @@ model Organization { primaryColor String @default("#1e40af") gcAccessToken String? gcEnvironment String @default("sandbox") + stripeSecretKey String? + stripeWebhookSecret String? whatsappConnected Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/pledge-now-pay-later/src/app/api/qr/[token]/route.ts b/pledge-now-pay-later/src/app/api/qr/[token]/route.ts index cf54467..1d0c101 100644 --- a/pledge-now-pay-later/src/app/api/qr/[token]/route.ts +++ b/pledge-now-pay-later/src/app/api/qr/[token]/route.ts @@ -16,7 +16,7 @@ export async function GET( if (token === "demo") { const event = await prisma.event.findFirst({ where: { status: "active" }, - include: { organization: { select: { name: true } } }, + include: { organization: { select: { name: true, stripeSecretKey: true } } }, orderBy: { createdAt: "asc" }, }) if (!event) { @@ -32,7 +32,7 @@ export async function GET( externalUrl: event.externalUrl || null, externalPlatform: event.externalPlatform || null, zakatEligible: event.zakatEligible || false, - + hasStripe: !!event.organization.stripeSecretKey, }) } @@ -41,7 +41,7 @@ export async function GET( include: { event: { include: { - organization: { select: { name: true } }, + organization: { select: { name: true, stripeSecretKey: true } }, }, }, }, @@ -67,7 +67,7 @@ export async function GET( externalUrl: qrSource.event.externalUrl || null, externalPlatform: qrSource.event.externalPlatform || null, zakatEligible: qrSource.event.zakatEligible || false, - + hasStripe: !!qrSource.event.organization.stripeSecretKey, }) } catch (error) { console.error("QR resolve error:", error) diff --git a/pledge-now-pay-later/src/app/api/settings/route.ts b/pledge-now-pay-later/src/app/api/settings/route.ts index 4bda7d1..1017d89 100644 --- a/pledge-now-pay-later/src/app/api/settings/route.ts +++ b/pledge-now-pay-later/src/app/api/settings/route.ts @@ -25,6 +25,8 @@ export async function GET(request: NextRequest) { primaryColor: org.primaryColor, gcAccessToken: org.gcAccessToken ? "••••••••" : "", gcEnvironment: org.gcEnvironment, + stripeSecretKey: org.stripeSecretKey ? "••••••••" : "", + stripeWebhookSecret: org.stripeWebhookSecret ? "••••••••" : "", orgType: org.orgType || "charity", }) } catch (error) { @@ -85,7 +87,7 @@ export async function PATCH(request: NextRequest) { if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 }) const body = await request.json() - const stringKeys = ["name", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo", "gcAccessToken", "gcEnvironment"] + const stringKeys = ["name", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo", "gcAccessToken", "gcEnvironment", "stripeSecretKey", "stripeWebhookSecret"] // eslint-disable-next-line @typescript-eslint/no-explicit-any const data: Record = {} for (const key of stringKeys) { diff --git a/pledge-now-pay-later/src/app/api/stripe/checkout/route.ts b/pledge-now-pay-later/src/app/api/stripe/checkout/route.ts new file mode 100644 index 0000000..bf06a85 --- /dev/null +++ b/pledge-now-pay-later/src/app/api/stripe/checkout/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { createCheckoutSession } from "@/lib/stripe" +import { generateReference } from "@/lib/reference" + +/** + * POST /api/stripe/checkout + * + * Creates a Stripe Checkout Session using the ORG'S own Stripe account. + * The money goes directly to the charity's Stripe balance. + * PNPL never touches the funds. + * + * If the org hasn't connected Stripe, returns { mode: "not_configured" }. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { amountPence, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId } = body + + if (!prisma) { + return NextResponse.json({ error: "Database not configured" }, { status: 503 }) + } + + // Get event + org (with Stripe key) + const event = await prisma.event.findUnique({ + where: { id: eventId }, + include: { organization: true }, + }) + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }) + } + + const org = event.organization + + // Check if org has Stripe configured + if (!org.stripeSecretKey) { + return NextResponse.json({ mode: "not_configured" }) + } + + // Generate reference + let reference = "" + let attempts = 0 + while (attempts < 10) { + reference = generateReference(org.refPrefix || "PNPL", amountPence) + const exists = await prisma.pledge.findUnique({ where: { reference } }) + if (!exists) break + attempts++ + } + + // Create pledge in DB + const pledge = await prisma.pledge.create({ + data: { + reference, + amountPence, + currency: "GBP", + rail: "card", + status: "new", + donorName: donorName || null, + donorEmail: donorEmail || null, + donorPhone: donorPhone || null, + giftAid: giftAid || false, + eventId, + qrSourceId: qrSourceId || null, + organizationId: org.id, + }, + }) + + // Track analytics + await prisma.analyticsEvent.create({ + data: { + eventType: "pledge_completed", + pledgeId: pledge.id, + eventId, + qrSourceId: qrSourceId || null, + metadata: { amountPence, rail: "card" }, + }, + }) + + // Create Stripe Checkout Session using the org's key + const baseUrl = process.env.BASE_URL || "http://localhost:3000" + + const session = await createCheckoutSession({ + stripeSecretKey: org.stripeSecretKey, + amountPence, + currency: "GBP", + pledgeId: pledge.id, + reference, + eventName: event.name, + organizationName: org.name, + donorEmail: donorEmail || undefined, + successUrl: `${baseUrl}/p/success?pledge_id=${pledge.id}&rail=card&session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${baseUrl}/p/success?pledge_id=${pledge.id}&rail=card&cancelled=true`, + }) + + if (session) { + // Save Stripe session reference + await prisma.payment.create({ + data: { + pledgeId: pledge.id, + provider: "stripe", + providerRef: session.sessionId, + amountPence, + status: "pending", + matchedBy: "auto", + }, + }) + + await prisma.pledge.update({ + where: { id: pledge.id }, + data: { status: "initiated" }, + }) + + return NextResponse.json({ + mode: "live", + pledgeId: pledge.id, + reference, + checkoutUrl: session.checkoutUrl, + sessionId: session.sessionId, + }) + } + + // Stripe call failed — clean up the pledge + await prisma.pledge.delete({ where: { id: pledge.id } }) + return NextResponse.json({ error: "Failed to create checkout session" }, { status: 500 }) + } catch (error) { + console.error("Stripe checkout error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/stripe/webhook/route.ts b/pledge-now-pay-later/src/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..28ddf1d --- /dev/null +++ b/pledge-now-pay-later/src/app/api/stripe/webhook/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { constructWebhookEvent } from "@/lib/stripe" + +/** + * POST /api/stripe/webhook + * + * Handles Stripe webhooks. Each org has their own webhook secret. + * We look up the org by the pledge_id in the session metadata. + * + * The org configures their Stripe webhook endpoint to point here: + * https://pledge.quikcue.com/api/stripe/webhook + * + * Events handled: + * - checkout.session.completed → mark pledge as paid + * - checkout.session.expired → mark pledge as cancelled + */ +export async function POST(request: NextRequest) { + try { + if (!prisma) { + return NextResponse.json({ error: "DB not configured" }, { status: 503 }) + } + + const body = await request.text() + const signature = request.headers.get("stripe-signature") || "" + + // We need to try all orgs' webhook secrets to find the right one + // In practice, most deployments have 1-5 orgs with Stripe configured + const orgsWithStripe = await prisma.organization.findMany({ + where: { stripeWebhookSecret: { not: null } }, + select: { id: true, stripeWebhookSecret: true }, + }) + + let event = null + let matchedOrgId: string | null = null + + for (const org of orgsWithStripe) { + if (!org.stripeWebhookSecret) continue + const parsed = constructWebhookEvent(body, signature, org.stripeWebhookSecret) + if (parsed) { + event = parsed + matchedOrgId = org.id + break + } + } + + if (!event || !matchedOrgId) { + return NextResponse.json({ error: "Webhook signature verification failed" }, { status: 400 }) + } + + // Handle checkout.session.completed + if (event.type === "checkout.session.completed") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = event.data.object as any + const pledgeId = session.metadata?.pledge_id + + if (pledgeId) { + // Verify this pledge belongs to the matched org + const pledge = await prisma.pledge.findFirst({ + where: { id: pledgeId, organizationId: matchedOrgId }, + }) + + if (pledge) { + await prisma.pledge.update({ + where: { id: pledgeId }, + data: { status: "paid", paidAt: new Date() }, + }) + + // Update payment record + await prisma.payment.updateMany({ + where: { pledgeId, provider: "stripe" }, + data: { status: "completed" }, + }) + + // Skip remaining reminders + await prisma.reminder.updateMany({ + where: { pledgeId, status: "pending" }, + data: { status: "skipped" }, + }) + + // Log + await prisma.analyticsEvent.create({ + data: { + eventType: "activity.stripe_payment_confirmed", + pledgeId, + eventId: pledge.eventId, + metadata: { provider: "stripe", sessionId: session.id }, + }, + }) + } + } + } + + // Handle checkout.session.expired + if (event.type === "checkout.session.expired") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = event.data.object as any + const pledgeId = session.metadata?.pledge_id + + if (pledgeId) { + // Reset to "new" so they can try again + await prisma.pledge.updateMany({ + where: { id: pledgeId, organizationId: matchedOrgId, status: "initiated" }, + data: { status: "new" }, + }) + await prisma.payment.updateMany({ + where: { pledgeId, provider: "stripe", status: "pending" }, + data: { status: "expired" }, + }) + } + } + + return NextResponse.json({ received: true }) + } catch (error) { + console.error("Stripe webhook error:", error) + return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/dashboard/settings/page.tsx b/pledge-now-pay-later/src/app/dashboard/settings/page.tsx index ffde8d2..06efc21 100644 --- a/pledge-now-pay-later/src/app/dashboard/settings/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/settings/page.tsx @@ -5,7 +5,7 @@ import { useSession } from "next-auth/react" import { Check, Loader2, AlertCircle, MessageCircle, Radio, RefreshCw, Smartphone, Wifi, WifiOff, QrCode, UserPlus, Trash2, Copy, - Users, Crown, Eye, Building2, CreditCard, Palette, ChevronRight + Users, Crown, Eye, Building2, CreditCard, Palette, ChevronRight, Zap } from "lucide-react" /** @@ -29,6 +29,7 @@ interface OrgSettings { name: string; bankName: string; bankSortCode: string; bankAccountNo: string bankAccountName: string; refPrefix: string; primaryColor: string gcAccessToken: string; gcEnvironment: string; orgType: string + stripeSecretKey: string; stripeWebhookSecret: string } interface TeamMember { @@ -148,6 +149,7 @@ export default function SettingsPage() { const bankReady = !!(settings.bankSortCode && settings.bankAccountNo && settings.bankAccountName) const whatsappReady = waStatus === "CONNECTED" const charityReady = !!settings.name + const stripeReady = !!settings.stripeSecretKey return (
@@ -161,11 +163,12 @@ export default function SettingsPage() { {/* ── Readiness bar — "Am I set up?" ── */}

Setup progress

-
+
{[ { label: "WhatsApp", ready: whatsappReady, detail: whatsappReady ? "Connected" : "Not connected" }, { label: "Bank details", ready: bankReady, detail: bankReady ? `${settings.bankSortCode}` : "Not set" }, { label: "Charity name", ready: charityReady, detail: charityReady ? settings.name : "Not set" }, + { label: "Card payments", ready: stripeReady, detail: stripeReady ? "Stripe connected" : "Not set up" }, { label: "Team", ready: team.length > 0, detail: `${team.length} member${team.length !== 1 ? "s" : ""}` }, ].map(item => (
@@ -429,7 +432,95 @@ export default function SettingsPage() {
- {/* ── 4. Charity details ── */} + {/* ── 4. Card payments (Stripe) ── */} +
+
+
+ +
+
+
+

Card payments

+ {stripeReady &&
} +
+

Let donors pay by card using your Stripe account

+
+
+ +
+
+

Connect your own Stripe account to accept card payments. Money goes directly to your Stripe balance — we never touch it.

+

When connected, donors see a third payment option: Bank Transfer, Direct Debit, and Card Payment.

+
+ + {!stripeReady && ( +
+

How to get your Stripe API key

+
    +
  1. Go to dashboard.stripe.com/apikeys
  2. +
  3. Copy your Secret key (starts with sk_live_ or sk_test_)
  4. +
  5. Paste it below
  6. +
+
+ )} + + update("stripeSecretKey", v)} + placeholder="sk_live_... or sk_test_..." + type="password" + /> + +
+ Webhook setup (optional — for auto-confirmation) +
+
+

If you want pledges to auto-confirm when the card is charged:

+
    +
  1. In Stripe Dashboard → Developers → Webhooks
  2. +
  3. Add endpoint: {typeof window !== "undefined" ? window.location.origin : ""}/api/stripe/webhook
  4. +
  5. Select events: checkout.session.completed
  6. +
  7. Copy the signing secret and paste below
  8. +
+
+ update("stripeWebhookSecret", v)} + placeholder="whsec_..." + type="password" + /> +

Without webhooks, you confirm card payments manually in the Money page (same as bank transfers).

+
+
+ + {stripeReady && ( +
+ +
+

Card payments are live

+

Donors will see “Pay by Card” as an option. They'll be redirected to Stripe's checkout page. Apple Pay and Google Pay work automatically.

+
+
+ )} + +
+

{stripeReady ? "Key stored securely · never shown to donors" : "Optional — bank transfer works without this"}

+ save("stripe", { + stripeSecretKey: settings.stripeSecretKey || "", + stripeWebhookSecret: settings.stripeWebhookSecret || "", + })} + /> +
+
+
+ + {/* ── 5. Charity details ── */}
diff --git a/pledge-now-pay-later/src/app/p/[token]/page.tsx b/pledge-now-pay-later/src/app/p/[token]/page.tsx index 9324c2d..19524b9 100644 --- a/pledge-now-pay-later/src/app/p/[token]/page.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/page.tsx @@ -9,9 +9,10 @@ import { IdentityStep } from "./steps/identity-step" import { ConfirmationStep } from "./steps/confirmation-step" import { BankInstructionsStep } from "./steps/bank-instructions-step" import { ExternalRedirectStep } from "./steps/external-redirect-step" +import { CardPaymentStep } from "./steps/card-payment-step" import { DirectDebitStep } from "./steps/direct-debit-step" -export type Rail = "bank" | "gocardless" +export type Rail = "bank" | "gocardless" | "card" export interface PledgeData { amountPence: number @@ -44,6 +45,7 @@ interface EventInfo { externalUrl: string | null externalPlatform: string | null zakatEligible: boolean + hasStripe: boolean } /* @@ -142,7 +144,7 @@ export default function PledgePage() { // Step 2: Payment method selected (only for "now" self-payment mode) const handleRailSelected = (rail: Rail) => { setPledgeData((d) => ({ ...d, rail })) - setStep(rail === "bank" ? 3 : 8) + setStep(rail === "bank" ? 3 : rail === "card" ? 6 : 8) } // Submit pledge (from identity step, or card/DD steps) @@ -225,7 +227,7 @@ export default function PledgePage() { const steps: Record = { 0: , 1: , - 2: , + 2: , 3: , 4: pledgeResult && , 5: pledgeResult && ( @@ -242,19 +244,20 @@ export default function PledgePage() { installmentAmount={pledgeData.installmentCount ? Math.ceil(pledgeData.amountPence / pledgeData.installmentCount) : undefined} /> ), + 6: , 7: pledgeResult && , 8: , } const backableSteps = new Set([1, 2, 3, 6, 8]) const getBackStep = (s: number): number => { - if (s === 8) return 2 // DD → payment method + if (s === 6 || s === 8) return 2 // card/DD → payment method if (s === 3 && pledgeData.scheduleMode !== "now") return 1 // deferred identity → schedule if (s === 3) return 2 // bank identity → payment method return s - 1 } - const progressMap: Record = { 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 7: 100, 8: 60 } + const progressMap: Record = { 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 6: 60, 7: 100, 8: 60 } const progressPercent = progressMap[step] || 10 return ( diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/card-payment-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/card-payment-step.tsx new file mode 100644 index 0000000..ba555af --- /dev/null +++ b/pledge-now-pay-later/src/app/p/[token]/steps/card-payment-step.tsx @@ -0,0 +1,165 @@ +"use client" + +import { useState } from "react" +import { CreditCard, Lock, Loader2, ExternalLink } from "lucide-react" + +/** + * Card Payment Step + * + * Redirects to the charity's own Stripe Checkout. + * No card form on our side — Stripe handles PCI compliance. + * The money goes directly to the charity's Stripe account. + * + * Flow: + * 1. Collect donor name + email (for receipt) + * 2. POST /api/stripe/checkout → get checkoutUrl + * 3. Redirect to Stripe Checkout + * 4. Stripe redirects back to /p/success + */ + +interface Props { + amount: number + eventName: string + eventId: string + qrSourceId: string | null + onComplete: (identity: { + donorName: string + donorEmail: string + donorPhone: string + giftAid: boolean + }) => void +} + +export function CardPaymentStep({ amount, eventName, eventId, qrSourceId }: Props) { + const [name, setName] = useState("") + const [email, setEmail] = useState("") + const [giftAid, setGiftAid] = useState(false) + const [processing, setProcessing] = useState(false) + const [error, setError] = useState(null) + + const pounds = (amount / 100).toFixed(2) + const isReady = name.trim().length > 1 && email.includes("@") + + const handleSubmit = async () => { + if (!isReady) return + setProcessing(true) + setError(null) + + try { + const res = await fetch("/api/stripe/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + amountPence: amount, + donorName: name.trim(), + donorEmail: email.trim(), + donorPhone: "", + giftAid, + eventId, + qrSourceId, + }), + }) + const data = await res.json() + + if (data.mode === "live" && data.checkoutUrl) { + // Redirect to Stripe Checkout (the charity's Stripe account) + window.location.href = data.checkoutUrl + return + } + + if (data.mode === "not_configured") { + setError("This charity hasn't set up card payments yet. Please use bank transfer instead.") + setProcessing(false) + return + } + + setError("Something went wrong creating the payment. Please try bank transfer instead.") + setProcessing(false) + } catch { + setError("Connection error. Please try again.") + setProcessing(false) + } + } + + return ( +
+
+

+ Pay by card +

+

+ £{pounds} for {eventName} +

+
+ +
+ {/* Name */} +
+ + setName(e.target.value)} + placeholder="Full name" + autoComplete="name" + className="w-full h-11 px-3 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none" + /> +
+ + {/* Email */} +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + autoComplete="email" + inputMode="email" + className="w-full h-11 px-3 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none" + /> +

For your payment receipt

+
+ + {/* Gift Aid */} + +
+ + {error && ( +
{error}
+ )} + + {/* Pay button */} + + +
+ Secure checkout + Powered by Stripe +
+ +
+

You'll be redirected to Stripe's secure checkout page. The payment goes directly to {eventName}'s account. Visa, Mastercard, Amex, Apple Pay, and Google Pay accepted.

+
+
+ ) +} diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx index a3837ac..a4bfb62 100644 --- a/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx @@ -61,6 +61,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do const railLabels: Record = { bank: "Bank Transfer", gocardless: "Direct Debit", + card: "Card Payment", } const deferredMessage = isDeferred @@ -72,6 +73,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do const nextStepMessages: Record = { bank: deferredMessage || "We've sent you payment instructions. Transfer at your convenience — we'll confirm once received.", gocardless: `Your Direct Debit mandate is set up. £${(amount / 100).toFixed(2)} will be collected automatically in 3-5 working days. Protected by the Direct Debit Guarantee.`, + card: "Your card payment has been processed. You'll receive a confirmation email from Stripe shortly.", } // Send WhatsApp receipt if phone provided @@ -138,7 +140,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do

{isDeferred ? "Pledge Locked In!" - : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"} + : rail === "card" ? "Payment Complete!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}

Thank you for your generous support of{" "} diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx index 6309d88..fee6e9a 100644 --- a/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx @@ -1,13 +1,14 @@ "use client" -import { Building2, Landmark, Shield, CheckCircle2 } from "lucide-react" +import { Building2, CreditCard, Landmark, Shield, CheckCircle2 } from "lucide-react" interface Props { - onSelect: (rail: "bank" | "gocardless") => void + onSelect: (rail: "bank" | "gocardless" | "card") => void amount: number + hasStripe?: boolean } -export function PaymentStep({ onSelect, amount }: Props) { +export function PaymentStep({ onSelect, amount, hasStripe }: Props) { const pounds = (amount / 100).toFixed(0) const giftAidTotal = ((amount + amount * 0.25) / 100).toFixed(0) @@ -40,6 +41,20 @@ export function PaymentStep({ onSelect, amount }: Props) { highlight: false, benefits: ["No action needed", "DD Guarantee"], }, + ...(hasStripe ? [{ + id: "card" as const, + icon: CreditCard, + title: "Card Payment", + subtitle: "Visa, Mastercard, Amex, Apple Pay", + tag: "Instant", + tagClass: "bg-purple-100 text-purple-700", + detail: "Secure checkout via Stripe. Receipt emailed instantly.", + fee: "Card fees apply", + feeClass: "text-muted-foreground", + iconBg: "bg-midnight", + highlight: false, + benefits: ["Instant confirmation", "Apple Pay & Google Pay"], + }] : []), ] return ( diff --git a/pledge-now-pay-later/src/app/p/success/page.tsx b/pledge-now-pay-later/src/app/p/success/page.tsx index 75a6755..1fb11ce 100644 --- a/pledge-now-pay-later/src/app/p/success/page.tsx +++ b/pledge-now-pay-later/src/app/p/success/page.tsx @@ -74,13 +74,15 @@ function SuccessContent() { } const railLabels: Record = { - gocardless: "Direct Debit", bank: "Bank Transfer", + gocardless: "Direct Debit", + card: "Card Payment", } const nextStepMessages: Record = { - gocardless: "Your Direct Debit mandate has been set up. The payment will be collected automatically in 3-5 working days.", bank: "Please complete the bank transfer using the reference provided.", + gocardless: "Your Direct Debit mandate has been set up. The payment will be collected automatically in 3-5 working days.", + card: "Your card payment has been processed. You'll receive a confirmation email from Stripe shortly.", } return ( diff --git a/pledge-now-pay-later/src/app/page.tsx b/pledge-now-pay-later/src/app/page.tsx index caa9fe6..4f547a1 100644 --- a/pledge-now-pay-later/src/app/page.tsx +++ b/pledge-now-pay-later/src/app/page.tsx @@ -663,6 +663,13 @@ export default function HomePage() { desc: "UK-native charity platform. Gift Aid handled at their end or ours — your choice.", tag: "Fundraising", }, + { + name: "Stripe", + logo: "/images/logos/stripe.svg", + color: "#635BFF", + desc: "Connect your own Stripe account. Donors pay by card, Apple Pay, or Google Pay. Money goes to you.", + tag: "Card payments", + }, { name: "UK Bank Transfer", logo: null, diff --git a/pledge-now-pay-later/src/app/privacy/page.tsx b/pledge-now-pay-later/src/app/privacy/page.tsx index 8e23476..0614ab4 100644 --- a/pledge-now-pay-later/src/app/privacy/page.tsx +++ b/pledge-now-pay-later/src/app/privacy/page.tsx @@ -112,6 +112,7 @@ export default function PrivacyPage() {

10. Third-Party Services

  • GoCardless — for Direct Debit mandate processing (if enabled by charity)
  • +
  • Stripe — for card payment processing (if enabled by charity — the charity connects their own Stripe account)
  • OpenAI — for AI-powered features (amount suggestions, reminder copy). No donor PII is sent to OpenAI — only anonymised context.
diff --git a/pledge-now-pay-later/src/app/terms/page.tsx b/pledge-now-pay-later/src/app/terms/page.tsx index 294a4bd..39c230a 100644 --- a/pledge-now-pay-later/src/app/terms/page.tsx +++ b/pledge-now-pay-later/src/app/terms/page.tsx @@ -38,7 +38,7 @@ export default function TermsPage() {

5. Payment Processing

-

PNPL is not a payment processor. We facilitate pledge tracking and follow-up. Actual payment flows through your bank account or GoCardless. We are not liable for payment disputes, chargebacks, or failed transactions.

+

PNPL is not a payment processor. We facilitate pledge tracking and follow-up. Actual payment flows through your bank account, GoCardless, or your own Stripe account. We are not liable for payment disputes, chargebacks, or failed transactions.

diff --git a/pledge-now-pay-later/src/lib/stripe.ts b/pledge-now-pay-later/src/lib/stripe.ts new file mode 100644 index 0000000..32962bd --- /dev/null +++ b/pledge-now-pay-later/src/lib/stripe.ts @@ -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 + } +} diff --git a/temp_files/v3/AppealResource.php b/temp_files/v3/AppealResource.php index ebd94cd..040d4c7 100644 --- a/temp_files/v3/AppealResource.php +++ b/temp_files/v3/AppealResource.php @@ -27,9 +27,6 @@ use Filament\Tables\Actions\ActionGroup; use Filament\Tables\Actions\BulkAction; use Filament\Tables\Actions\EditAction; use Filament\Tables\Columns\TextColumn; -use Filament\Tables\Enums\FiltersLayout; -use Filament\Tables\Filters\Filter; -use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Builder; @@ -310,47 +307,7 @@ class AppealResource extends Resource ->sortable() ->description(fn (Appeal $a) => $a->created_at?->format('d M Y')), ]) - ->filters([ - SelectFilter::make('nurture_segment') - ->label('Nurture Segment') - ->options([ - 'needs_outreach' => '🔴 Needs Outreach (£0 raised, 7+ days)', - 'almost_there' => '🟡 Almost There (80%+ of target)', - 'target_reached' => '🟢 Target Reached', - 'slowing' => '🟠 Slowing Down (raised something, 30+ days)', - 'new_this_week' => '🆕 New This Week', - ]) - ->query(function (Builder $query, array $data) { - if (!$data['value']) return; - $query->where('status', 'confirmed')->where('is_accepting_donations', true); - - match ($data['value']) { - 'needs_outreach' => $query->where('amount_raised', 0)->where('created_at', '<', now()->subDays(7)), - 'almost_there' => $query->where('amount_raised', '>', 0)->whereRaw('amount_raised >= amount_to_raise * 0.8')->whereRaw('amount_raised < amount_to_raise'), - 'target_reached' => $query->whereRaw('amount_raised >= amount_to_raise')->where('amount_raised', '>', 0), - 'slowing' => $query->where('amount_raised', '>', 0)->whereRaw('amount_raised < amount_to_raise * 0.8')->where('created_at', '<', now()->subDays(30)), - 'new_this_week' => $query->where('created_at', '>=', now()->subDays(7)), - default => null, - }; - }), - - SelectFilter::make('status') - ->options([ - 'confirmed' => 'Live', - 'pending' => 'Pending Review', - ]), - - Filter::make('accepting_donations') - ->label('Currently accepting donations') - ->toggle() - ->query(fn (Builder $q) => $q->where('is_accepting_donations', true)) - ->default(), - - Filter::make('has_raised') - ->label('Has raised money') - ->toggle() - ->query(fn (Builder $q) => $q->where('amount_raised', '>', 0)), - ], layout: FiltersLayout::AboveContentCollapsible) + ->filters([]) ->actions([ Action::make('view_page') ->label('View Page') diff --git a/temp_files/v3/CustomerResource.php b/temp_files/v3/CustomerResource.php index 7ef659b..fdcb4a7 100644 --- a/temp_files/v3/CustomerResource.php +++ b/temp_files/v3/CustomerResource.php @@ -17,10 +17,8 @@ use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\GlobalSearch\Actions\Action as GlobalSearchAction; use Filament\Resources\Resource; -use Filament\Tables\Actions\Action; use Filament\Tables\Actions\EditAction; use Filament\Tables\Columns\TextColumn; -use Filament\Tables\Filters\Filter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -207,56 +205,7 @@ class CustomerResource extends Resource ->since() ->sortable(), ]) - ->filters([ - Filter::make('has_donations') - ->label('Has donated') - ->toggle() - ->query(fn (Builder $q) => $q->has('donations')), - - Filter::make('monthly_supporter') - ->label('Monthly supporter') - ->toggle() - ->query(fn (Builder $q) => $q->whereHas( - 'scheduledGivingDonations', - fn ($q2) => $q2->where('is_active', true) - )), - - Filter::make('gift_aid') - ->label('Gift Aid donors') - ->toggle() - ->query(fn (Builder $q) => $q->whereHas( - 'donations', - fn ($q2) => $q2->whereHas('donationPreferences', fn ($q3) => $q3->where('is_gift_aid', true)) - )), - - Filter::make('major_donor') - ->label('Major donors (£1000+)') - ->toggle() - ->query(function (Builder $q) { - $q->whereIn('id', function ($sub) { - $sub->select('customer_id') - ->from('donations') - ->join('donation_confirmations', 'donations.id', '=', 'donation_confirmations.donation_id') - ->whereNotNull('donation_confirmations.confirmed_at') - ->groupBy('customer_id') - ->havingRaw('SUM(donations.amount) >= 100000'); - }); - }), - - Filter::make('incomplete_donations') - ->label('Has incomplete donations') - ->toggle() - ->query(fn (Builder $q) => $q->whereHas( - 'donations', - fn ($q2) => $q2->whereDoesntHave('donationConfirmation', fn ($q3) => $q3->whereNotNull('confirmed_at')) - ->where('created_at', '>=', now()->subDays(30)) - )), - - Filter::make('recent') - ->label('Joined last 30 days') - ->toggle() - ->query(fn (Builder $q) => $q->where('created_at', '>=', now()->subDays(30))), - ]) + ->filters([]) ->actions([ EditAction::make() ->label('Open') diff --git a/temp_files/v3/DonationResource.php b/temp_files/v3/DonationResource.php index c5bbb9c..8ea2be1 100644 --- a/temp_files/v3/DonationResource.php +++ b/temp_files/v3/DonationResource.php @@ -34,10 +34,8 @@ use Filament\Tables\Actions\ExportAction; use Filament\Tables\Actions\ViewAction; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; -use Filament\Tables\Enums\FiltersLayout; use Filament\Tables\Filters\Filter; use Filament\Tables\Filters\SelectFilter; -use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -302,7 +300,7 @@ class DonationResource extends Resource ->copyable() ->toggleable(isToggledHiddenByDefault: true), ]) - ->filters(static::getTableFilters(), layout: FiltersLayout::AboveContentCollapsible) + ->filters(static::getTableFilters()) ->actions(static::getTableRowActions()) ->bulkActions(static::getBulkActions()) ->headerActions([ @@ -431,27 +429,11 @@ class DonationResource extends Resource ]); } - // ─── Filters ───────────────────────────────────────────────── - // Designed around real questions: - // "Show me today's incomplete donations" (investigating failures) - // "Show me Zakat donations this month" (reporting) - // "Show me donations to a specific cause" (allocation check) + // ─── Filters (drill-down within tabs) ─────────────────────── private static function getTableFilters(): array { return [ - TernaryFilter::make('confirmed') - ->label('Payment Status') - ->placeholder('All') - ->trueLabel('Confirmed only') - ->falseLabel('Incomplete only') - ->queries( - true: fn (Builder $q) => $q->whereHas('donationConfirmation', fn ($q2) => $q2->whereNotNull('confirmed_at')), - false: fn (Builder $q) => $q->whereDoesntHave('donationConfirmation', fn ($q2) => $q2->whereNotNull('confirmed_at')), - blank: fn (Builder $q) => $q, - ) - ->default(true), - SelectFilter::make('donation_type_id') ->label('Cause') ->options(fn () => DonationType::orderBy('display_name')->pluck('display_name', 'id')) @@ -473,21 +455,6 @@ class DonationResource extends Resource ->when($data['to'], fn (Builder $q) => $q->whereDate('created_at', '<=', $data['to'])); }) ->columns(2), - - Filter::make('is_zakat') - ->label('Zakat') - ->toggle() - ->query(fn (Builder $q) => $q->whereHas('donationPreferences', fn ($q2) => $q2->where('is_zakat', true))), - - Filter::make('is_gift_aid') - ->label('Gift Aid') - ->toggle() - ->query(fn (Builder $q) => $q->whereHas('donationPreferences', fn ($q2) => $q2->where('is_gift_aid', true))), - - Filter::make('has_fundraiser') - ->label('Via Fundraiser') - ->toggle() - ->query(fn (Builder $q) => $q->whereNotNull('appeal_id')), ]; } } diff --git a/temp_files/v4/AdminPanelProvider.php b/temp_files/v4/AdminPanelProvider.php new file mode 100644 index 0000000..f318957 --- /dev/null +++ b/temp_files/v4/AdminPanelProvider.php @@ -0,0 +1,88 @@ +default() + ->id('admin') + ->path('admin') + ->login() + ->colors(['primary' => config('branding.colours')]) + ->viteTheme('resources/css/filament/admin/theme.css') + ->sidebarCollapsibleOnDesktop() + ->sidebarWidth('16rem') + ->globalSearch(true) + ->globalSearchKeyBindings(['command+k', 'ctrl+k']) + ->globalSearchDebounce('300ms') + ->navigationGroups([ + // ── Daily Work (always visible, top of sidebar) ── + NavigationGroup::make('Daily') + ->collapsible(false), + + // ── Fundraising (campaigns, review queue) ── + NavigationGroup::make('Fundraising') + ->icon('heroicon-o-megaphone') + ->collapsible(), + + // ── Setup (rarely touched config) ── + NavigationGroup::make('Setup') + ->icon('heroicon-o-cog-6-tooth') + ->collapsible() + ->collapsed(), + ]) + ->brandLogo(Helpers::getCurrentLogo(true)) + ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') + ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages') + ->pages([\Filament\Pages\Dashboard::class]) + ->userMenuItems([ + 'profile' => MenuItem::make() + ->label('Edit profile') + ->url(url('user/profile')), + + 'back2site' => MenuItem::make() + ->label('Return to site') + ->icon('heroicon-o-home') + ->url(url('/')), + ]) + ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') + ->middleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + AuthenticateSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + ]) + ->authMiddleware([ + Authenticate::class, + ]) + ->login(null) + ->registration(null) + ->darkMode(false) + ->databaseNotifications(); + } +} diff --git a/temp_files/v4/ListAppeals.php b/temp_files/v4/ListAppeals.php new file mode 100644 index 0000000..523b166 --- /dev/null +++ b/temp_files/v4/ListAppeals.php @@ -0,0 +1,86 @@ +where('is_accepting_donations', true)->count(); + return "{$live} fundraisers are live right now."; + } + + public function getTabs(): array + { + $needsHelp = Appeal::where('status', 'confirmed') + ->where('is_accepting_donations', true) + ->where('amount_raised', 0) + ->where('created_at', '<', now()->subDays(7)) + ->where('created_at', '>', now()->subDays(90)) + ->count(); + + $almostThere = Appeal::where('status', 'confirmed') + ->where('is_accepting_donations', true) + ->where('amount_raised', '>', 0) + ->whereRaw('amount_raised >= amount_to_raise * 0.8') + ->whereRaw('amount_raised < amount_to_raise') + ->count(); + + return [ + 'live' => Tab::make('Live') + ->icon('heroicon-o-signal') + ->modifyQueryUsing(fn (Builder $q) => $q + ->where('status', 'confirmed') + ->where('is_accepting_donations', true) + ), + + 'needs_help' => Tab::make('Needs Outreach') + ->icon('heroicon-o-hand-raised') + ->badge($needsHelp > 0 ? $needsHelp : null) + ->badgeColor('danger') + ->modifyQueryUsing(fn (Builder $q) => $q + ->where('status', 'confirmed') + ->where('is_accepting_donations', true) + ->where('amount_raised', 0) + ->where('created_at', '<', now()->subDays(7)) + ->where('created_at', '>', now()->subDays(90)) + ), + + 'almost' => Tab::make('Almost There') + ->icon('heroicon-o-fire') + ->badge($almostThere > 0 ? $almostThere : null) + ->badgeColor('warning') + ->modifyQueryUsing(fn (Builder $q) => $q + ->where('status', 'confirmed') + ->where('is_accepting_donations', true) + ->where('amount_raised', '>', 0) + ->whereRaw('amount_raised >= amount_to_raise * 0.8') + ->whereRaw('amount_raised < amount_to_raise') + ), + + 'hit_target' => Tab::make('Target Reached') + ->icon('heroicon-o-trophy') + ->modifyQueryUsing(fn (Builder $q) => $q + ->where('status', 'confirmed') + ->where('amount_raised', '>', 0) + ->whereRaw('amount_raised >= amount_to_raise') + ), + + 'all' => Tab::make('Everything') + ->icon('heroicon-o-squares-2x2'), + ]; + } +} diff --git a/temp_files/v4/ListApprovalQueues.php b/temp_files/v4/ListApprovalQueues.php new file mode 100644 index 0000000..08b1380 --- /dev/null +++ b/temp_files/v4/ListApprovalQueues.php @@ -0,0 +1,50 @@ +count(); + if ($pending === 0) return 'All caught up — no fundraisers waiting for review.'; + return "{$pending} fundraisers waiting for your review."; + } + + public function getTabs(): array + { + $pending = ApprovalQueue::where('status', 'pending')->count(); + + return [ + 'pending' => Tab::make('Needs Review') + ->icon('heroicon-o-clock') + ->badge($pending > 0 ? $pending : null) + ->badgeColor('danger') + ->modifyQueryUsing(fn (Builder $q) => $q->where('status', 'pending')), + + 'approved' => Tab::make('Approved') + ->icon('heroicon-o-check-circle') + ->modifyQueryUsing(fn (Builder $q) => $q->where('status', 'confirmed')), + + 'rejected' => Tab::make('Rejected') + ->icon('heroicon-o-x-circle') + ->modifyQueryUsing(fn (Builder $q) => $q->where('status', 'change_requested')), + + 'all' => Tab::make('All') + ->icon('heroicon-o-squares-2x2'), + ]; + } +} diff --git a/temp_files/v4/ListCustomers.php b/temp_files/v4/ListCustomers.php new file mode 100644 index 0000000..bc2f64d --- /dev/null +++ b/temp_files/v4/ListCustomers.php @@ -0,0 +1,62 @@ + Tab::make('All Donors') + ->icon('heroicon-o-users'), + + 'monthly' => Tab::make('Monthly Supporters') + ->icon('heroicon-o-arrow-path') + ->modifyQueryUsing(fn (Builder $query) => $query + ->whereHas('scheduledGivingDonations', fn ($q) => $q->where('is_active', true)) + ), + + 'major' => Tab::make('Major Donors') + ->icon('heroicon-o-star') + ->modifyQueryUsing(fn (Builder $query) => $query + ->whereIn('id', function ($sub) { + $sub->select('customer_id') + ->from('donations') + ->join('donation_confirmations', 'donations.id', '=', 'donation_confirmations.donation_id') + ->whereNotNull('donation_confirmations.confirmed_at') + ->groupBy('customer_id') + ->havingRaw('SUM(donations.amount) >= 100000'); + }) + ), + + 'recent' => Tab::make('New (30 days)') + ->icon('heroicon-o-sparkles') + ->modifyQueryUsing(fn (Builder $query) => $query + ->where('created_at', '>=', now()->subDays(30)) + ), + ]; + } + + protected function getHeaderActions(): array + { + return []; + } +} diff --git a/temp_files/v4/ListDonations.php b/temp_files/v4/ListDonations.php new file mode 100644 index 0000000..037832f --- /dev/null +++ b/temp_files/v4/ListDonations.php @@ -0,0 +1,79 @@ + $q->whereNotNull('confirmed_at')) + ->whereDate('created_at', today()) + ->count(); + $todayAmount = Donation::whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at')) + ->whereDate('created_at', today()) + ->sum('amount') / 100; + + return "Today: {$todayCount} confirmed (£" . number_format($todayAmount, 0) . ")"; + } + + public function getTabs(): array + { + $incompleteCount = Donation::whereDoesntHave('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at')) + ->where('created_at', '>=', now()->subDays(7)) + ->count(); + + return [ + 'today' => Tab::make('Today') + ->icon('heroicon-o-clock') + ->modifyQueryUsing(fn (Builder $query) => $query + ->whereDate('created_at', today()) + ->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at')) + ), + + 'all_confirmed' => Tab::make('All Confirmed') + ->icon('heroicon-o-check-circle') + ->modifyQueryUsing(fn (Builder $query) => $query + ->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at')) + ), + + 'incomplete' => Tab::make('Incomplete') + ->icon('heroicon-o-exclamation-triangle') + ->badge($incompleteCount > 0 ? $incompleteCount : null) + ->badgeColor('danger') + ->modifyQueryUsing(fn (Builder $query) => $query + ->whereDoesntHave('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at')) + ->where('created_at', '>=', now()->subDays(7)) + ), + + 'zakat' => Tab::make('Zakat') + ->icon('heroicon-o-star') + ->modifyQueryUsing(fn (Builder $query) => $query + ->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at')) + ->whereHas('donationPreferences', fn ($q) => $q->where('is_zakat', true)) + ), + + 'gift_aid' => Tab::make('Gift Aid') + ->icon('heroicon-o-gift') + ->modifyQueryUsing(fn (Builder $query) => $query + ->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at')) + ->whereHas('donationPreferences', fn ($q) => $q->where('is_gift_aid', true)) + ), + + 'everything' => Tab::make('Everything') + ->icon('heroicon-o-squares-2x2'), + ]; + } +} diff --git a/temp_files/v4/ListScheduledGivingDonations.php b/temp_files/v4/ListScheduledGivingDonations.php new file mode 100644 index 0000000..548ca3f --- /dev/null +++ b/temp_files/v4/ListScheduledGivingDonations.php @@ -0,0 +1,53 @@ +count(); + return "{$active} people giving every month."; + } + + public function getTabs(): array + { + $active = ScheduledGivingDonation::where('is_active', true)->count(); + $inactive = ScheduledGivingDonation::where('is_active', false)->count(); + + return [ + 'active' => Tab::make('Active') + ->icon('heroicon-o-check-circle') + ->badge($active) + ->badgeColor('success') + ->modifyQueryUsing(fn (Builder $q) => $q->where('is_active', true)), + + 'cancelled' => Tab::make('Cancelled') + ->icon('heroicon-o-x-circle') + ->badge($inactive > 0 ? $inactive : null) + ->badgeColor('gray') + ->modifyQueryUsing(fn (Builder $q) => $q->where('is_active', false)), + + 'all' => Tab::make('All') + ->icon('heroicon-o-squares-2x2'), + ]; + } + + protected function getHeaderActions(): array + { + return []; + } +} diff --git a/temp_files/v4/nav_changes.py b/temp_files/v4/nav_changes.py new file mode 100644 index 0000000..1d2de65 --- /dev/null +++ b/temp_files/v4/nav_changes.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +""" +Reassign every Filament resource to the correct navigation group. + +SIDEBAR STRUCTURE: + Daily (always open, no collapse) + ├ Donors — "Find a donor, help them" + ├ Donations — "See what came in, investigate issues" + └ Regular Giving — "Monthly supporters" + + Fundraising + ├ Review Queue (84 pending badge) + ├ All Fundraisers + └ Scheduled Campaigns (3 only) + + Setup (collapsed by default — rarely touched) + ├ Causes (30) + ├ Countries (12) + ├ URL Builder + ├ Settings + ├ Users + ├ Activity Log + └ (hidden): Snowdon, Campaign Regs, WOH, Engage dims, Fund dims +""" + +import re, os + +BASE = '/home/forge/app.charityright.org.uk' + +changes = { + # ── DAILY (top 3 — the only pages staff use every day) ── + 'app/Filament/Resources/CustomerResource.php': { + 'navigationGroup': 'Daily', + 'navigationIcon': 'heroicon-o-user-circle', + 'navigationSort': 1, + 'navigationLabel': 'Donors', + }, + 'app/Filament/Resources/DonationResource.php': { + 'navigationGroup': 'Daily', + 'navigationIcon': 'heroicon-o-banknotes', + 'navigationSort': 2, + 'navigationLabel': 'Donations', + }, + 'app/Filament/Resources/ScheduledGivingDonationResource.php': { + 'navigationGroup': 'Daily', + 'navigationIcon': 'heroicon-o-arrow-path', + 'navigationSort': 3, + 'navigationLabel': 'Regular Giving', + }, + + # ── FUNDRAISING ── + 'app/Filament/Resources/ApprovalQueueResource.php': { + 'navigationGroup': 'Fundraising', + 'navigationIcon': 'heroicon-o-shield-check', + 'navigationSort': 1, + 'navigationLabel': 'Review Queue', + }, + 'app/Filament/Resources/AppealResource.php': { + 'navigationGroup': 'Fundraising', + 'navigationIcon': 'heroicon-o-hand-raised', + 'navigationSort': 2, + 'navigationLabel': 'All Fundraisers', + }, + 'app/Filament/Resources/ScheduledGivingCampaignResource.php': { + 'navigationGroup': 'Fundraising', + 'navigationIcon': 'heroicon-o-calendar', + 'navigationSort': 3, + 'navigationLabel': 'Giving Campaigns', + }, + + # ── SETUP (collapsed, rarely used) ── + 'app/Filament/Resources/DonationTypeResource.php': { + 'navigationGroup': 'Setup', + 'navigationIcon': 'heroicon-o-tag', + 'navigationSort': 1, + 'navigationLabel': 'Causes', + }, + 'app/Filament/Resources/DonationCountryResource.php': { + 'navigationGroup': 'Setup', + 'navigationIcon': 'heroicon-o-globe-alt', + 'navigationSort': 2, + 'navigationLabel': 'Countries', + }, + 'app/Filament/Resources/UserResource.php': { + 'navigationGroup': 'Setup', + 'navigationIcon': 'heroicon-o-users', + 'navigationSort': 3, + 'navigationLabel': 'Admin Users', + }, + 'app/Filament/Resources/EventLogResource.php': { + 'navigationGroup': 'Setup', + 'navigationIcon': 'heroicon-o-exclamation-triangle', + 'navigationSort': 4, + 'navigationLabel': 'Activity Log', + }, +} + +# Resources to HIDE from navigation entirely (dead data, 0 recent activity) +hide = [ + 'app/Filament/Resources/SnowdonRegistrationResource.php', + 'app/Filament/Resources/CampaignRegistrationResource.php', + 'app/Filament/Resources/WOHMessageResource.php', + 'app/Filament/Resources/EngageAttributionDimensionResource.php', + 'app/Filament/Resources/EngageFundDimensionResource.php', +] + +# Pages +page_changes = { + 'app/Filament/Pages/DonationURLBuilder.php': { + 'navigationGroup': 'Setup', + 'navigationSort': 5, + 'navigationLabel': 'URL Builder', + }, + 'app/Filament/Pages/Settings.php': { + 'navigationGroup': 'Setup', + 'navigationSort': 6, + }, +} + + +def update_static_property(content, prop, value): + """Replace a static property value in a PHP class.""" + # Match: protected static ?string $prop = 'old_value'; + # or: protected static ?int $prop = 123; + pattern = rf"(protected\s+static\s+\?(?:string|int)\s+\${prop}\s*=\s*)('[^']*'|\d+)(\s*;)" + + if isinstance(value, int): + replacement = rf"\g<1>{value}\3" + else: + replacement = rf"\g<1>'{value}'\3" + + new_content, count = re.subn(pattern, replacement, content) + return new_content, count + + +def apply_changes(filepath, props): + full = os.path.join(BASE, filepath) + with open(full, 'r') as f: + content = f.read() + + for prop, value in props.items(): + content, count = update_static_property(content, prop, value) + if count == 0: + print(f" WARNING: {prop} not found in {filepath}") + + with open(full, 'w') as f: + f.write(content) + print(f"Updated: {filepath}") + + +def hide_from_nav(filepath): + full = os.path.join(BASE, filepath) + with open(full, 'r') as f: + content = f.read() + + # Add shouldRegisterNavigation = false if not present + if 'shouldRegisterNavigation' not in content: + # Insert after the class opening + content = content.replace( + 'protected static ?string $navigationIcon', + 'protected static bool $shouldRegisterNavigation = false;\n\n protected static ?string $navigationIcon' + ) + with open(full, 'w') as f: + f.write(content) + print(f"Hidden: {filepath}") + else: + print(f"Already hidden: {filepath}") + + +# Apply all changes +print("=== UPDATING NAVIGATION ===") +for filepath, props in changes.items(): + apply_changes(filepath, props) + +print("\n=== HIDING DEAD PAGES ===") +for filepath in hide: + hide_from_nav(filepath) + +print("\n=== UPDATING PAGES ===") +for filepath, props in page_changes.items(): + apply_changes(filepath, props) + +print("\nDone!")