diff --git a/pledge-now-pay-later/prisma/schema.prisma b/pledge-now-pay-later/prisma/schema.prisma
index d983fd8..651b6cd 100644
--- a/pledge-now-pay-later/prisma/schema.prisma
+++ b/pledge-now-pay-later/prisma/schema.prisma
@@ -135,6 +135,16 @@ model Pledge {
iPaidClickedAt DateTime?
notes String?
+ // --- Conditional / Match Funding ---
+ // A conditional pledge activates only when a condition is met.
+ // e.g. "I'll give £5,000 if you raise £20,000" or "I'll match up to £5,000"
+ isConditional Boolean @default(false)
+ conditionType String? // "threshold" | "match" | "custom"
+ conditionText String? // Human-readable: "If £20,000 is raised"
+ conditionThreshold Int? // pence — when event total reaches this, condition is met
+ conditionMet Boolean @default(false)
+ conditionMetAt DateTime?
+
// Payment scheduling — the core of "pledge now, pay later"
dueDate DateTime? // null = pay now, set = promise to pay on this date
planId String? // groups installments together
diff --git a/pledge-now-pay-later/screenshots/collect-new.png b/pledge-now-pay-later/screenshots/collect-new.png
new file mode 100644
index 0000000..2952a35
Binary files /dev/null and b/pledge-now-pay-later/screenshots/collect-new.png differ
diff --git a/pledge-now-pay-later/screenshots/collect-with-data.png b/pledge-now-pay-later/screenshots/collect-with-data.png
new file mode 100644
index 0000000..2952a35
Binary files /dev/null and b/pledge-now-pay-later/screenshots/collect-with-data.png differ
diff --git a/pledge-now-pay-later/src/app/api/cron/reminders/route.ts b/pledge-now-pay-later/src/app/api/cron/reminders/route.ts
index b59896b..f598ce7 100644
--- a/pledge-now-pay-later/src/app/api/cron/reminders/route.ts
+++ b/pledge-now-pay-later/src/app/api/cron/reminders/route.ts
@@ -46,6 +46,11 @@ export async function GET(request: NextRequest) {
dueDate: { gte: todayStart, lt: todayEnd },
reminderSentForDueDate: false,
status: { notIn: ["paid", "cancelled"] },
+ // Don't send reminders for conditional pledges that haven't been met
+ OR: [
+ { isConditional: false },
+ { isConditional: true, conditionMet: true },
+ ],
},
include: {
event: {
@@ -185,6 +190,11 @@ export async function GET(request: NextRequest) {
scheduledAt: { lte: now },
pledge: {
status: { notIn: ["paid", "cancelled"] },
+ // Don't send reminders for unmet conditional pledges
+ OR: [
+ { isConditional: false },
+ { isConditional: true, conditionMet: true },
+ ],
},
},
include: {
diff --git a/pledge-now-pay-later/src/app/api/dashboard/route.ts b/pledge-now-pay-later/src/app/api/dashboard/route.ts
index b123f10..5351f22 100644
--- a/pledge-now-pay-later/src/app/api/dashboard/route.ts
+++ b/pledge-now-pay-later/src/app/api/dashboard/route.ts
@@ -84,7 +84,12 @@ export async function GET(request: NextRequest) {
}),
]) as [PledgeRow[], AnalyticsRow[]]
- const totalPledged = pledges.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const confirmedPledges = pledges.filter((p: any) => !p.isConditional || p.conditionMet)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const conditionalPledges = pledges.filter((p: any) => p.isConditional && !p.conditionMet)
+ const totalPledged = confirmedPledges.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
+ const totalConditional = conditionalPledges.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
const totalCollected = pledges
.filter((p: PledgeRow) => p.status === "paid")
.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
@@ -123,6 +128,8 @@ export async function GET(request: NextRequest) {
totalPledges: pledges.length,
totalPledgedPence: totalPledged,
totalCollectedPence: totalCollected,
+ totalConditionalPence: totalConditional,
+ conditionalCount: conditionalPledges.length,
collectionRate: Math.round(collectionRate * 100),
overdueRate: Math.round(overdueRate * 100),
},
@@ -148,6 +155,12 @@ export async function GET(request: NextRequest) {
installmentNumber: p.installmentNumber,
installmentTotal: p.installmentTotal,
isDeferred: !!p.dueDate,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ isConditional: (p as any).isConditional || false,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ conditionText: (p as any).conditionText || null,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ conditionMet: (p as any).conditionMet || false,
createdAt: p.createdAt,
paidAt: p.paidAt,
nextReminder: p.reminders
diff --git a/pledge-now-pay-later/src/app/api/events/route.ts b/pledge-now-pay-later/src/app/api/events/route.ts
index 68b876b..c97ab96 100644
--- a/pledge-now-pay-later/src/app/api/events/route.ts
+++ b/pledge-now-pay-later/src/app/api/events/route.ts
@@ -37,7 +37,7 @@ export async function GET(request: NextRequest) {
include: {
_count: { select: { pledges: true, qrSources: true } },
pledges: {
- select: { amountPence: true, status: true },
+ select: { amountPence: true, status: true, isConditional: true, conditionMet: true },
},
},
orderBy: { createdAt: "desc" },
@@ -57,7 +57,12 @@ export async function GET(request: NextRequest) {
externalUrl: e.externalUrl || null,
pledgeCount: e._count.pledges,
qrSourceCount: e._count.qrSources,
- totalPledged: e.pledges.reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ totalPledged: e.pledges.filter((p: any) => !p.isConditional || p.conditionMet).reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ totalConditional: e.pledges.filter((p: any) => p.isConditional && !p.conditionMet).reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ conditionalCount: e.pledges.filter((p: any) => p.isConditional && !p.conditionMet).length,
totalCollected: e.pledges
.filter((p: PledgeSummary) => p.status === "paid")
.reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
diff --git a/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts b/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts
index 95aa1a7..5d3fe53 100644
--- a/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts
+++ b/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts
@@ -75,6 +75,12 @@ export async function GET(request: NextRequest) {
days_to_collect: p.paidAt
? Math.ceil((p.paidAt.getTime() - p.createdAt.getTime()) / (1000 * 60 * 60 * 24)).toString()
: "",
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ is_conditional: (p as any).isConditional ? "Yes" : "No",
+ condition_type: (p as any).conditionType || "",
+ condition_text: (p as any).conditionText || "",
+ condition_met: (p as any).conditionMet ? "Yes" : "No",
+ /* eslint-enable @typescript-eslint/no-explicit-any */
}))
const csv = formatCrmExportCsv(rows)
diff --git a/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts b/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts
index d16d2b2..4d01bcc 100644
--- a/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts
+++ b/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts
@@ -108,6 +108,35 @@ export async function PATCH(
} catch { /* conversion tracking is best-effort */ }
}
+ // Re-check conditional thresholds when pledge status changes
+ // (paid pledges increase total, potentially unlocking conditional pledges)
+ if (parsed.data.status === "paid" || parsed.data.status === "cancelled") {
+ try {
+ // Import checkConditionalThresholds dynamically — it lives in the pledges route
+ // but we'll inline the logic here for simplicity
+ const result = await prisma.pledge.aggregate({
+ where: { eventId: existing.eventId, status: { notIn: ["cancelled"] }, isConditional: false },
+ _sum: { amountPence: true },
+ })
+ const totalRaised = result._sum.amountPence || 0
+ const unmet = await prisma.pledge.findMany({
+ where: { eventId: existing.eventId, isConditional: true, conditionMet: false, conditionThreshold: { not: null, lte: totalRaised }, status: { notIn: ["cancelled"] } },
+ })
+ const now = new Date()
+ for (const cp of unmet) {
+ await prisma.pledge.update({ where: { id: cp.id }, data: { conditionMet: true, conditionMetAt: now } })
+ if (cp.donorPhone) {
+ const { sendWhatsAppMessage } = await import("@/lib/whatsapp")
+ const ev = await prisma.event.findUnique({ where: { id: existing.eventId }, select: { name: true } })
+ sendWhatsAppMessage(cp.donorPhone,
+ `Assalamu Alaikum ${cp.donorName?.split(" ")[0] || "there"} 🎉\n\nGreat news — *${ev?.name || "the appeal"}* has reached its target!\n\nYour pledge of *£${(cp.amountPence / 100).toFixed(0)}* is now active.\n\nReference: \`${cp.reference}\`\n\nReply *HELP* for payment details.`
+ ).catch(() => {})
+ }
+ console.log(`[CONDITIONAL] Unlocked ${cp.reference} — threshold met`)
+ }
+ } catch (err) { console.error("[CONDITIONAL] Threshold check failed:", err) }
+ }
+
// Log activity
const changes = Object.keys(updateData).filter(k => k !== "paidAt" && k !== "cancelledAt")
await logActivity({
diff --git a/pledge-now-pay-later/src/app/api/pledges/route.ts b/pledge-now-pay-later/src/app/api/pledges/route.ts
index 050934a..7bda8c8 100644
--- a/pledge-now-pay-later/src/app/api/pledges/route.ts
+++ b/pledge-now-pay-later/src/app/api/pledges/route.ts
@@ -79,6 +79,13 @@ export async function GET(request: NextRequest) {
volunteerName: p.qrSource?.volunteerName || null,
createdAt: p.createdAt,
paidAt: p.paidAt,
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ isConditional: (p as any).isConditional || false,
+ conditionType: (p as any).conditionType || null,
+ conditionText: (p as any).conditionText || null,
+ conditionThreshold: (p as any).conditionThreshold || null,
+ conditionMet: (p as any).conditionMet || false,
+ /* eslint-enable @typescript-eslint/no-explicit-any */
})),
total,
limit,
@@ -117,7 +124,7 @@ export async function POST(request: NextRequest) {
)
}
- const { amountPence, rail, donorName, donorEmail, donorPhone, donorAddressLine1, donorPostcode, giftAid, isZakat, emailOptIn, whatsappOptIn, consentMeta, eventId, qrSourceId, scheduleMode, dueDate, installmentCount, installmentDates } = parsed.data
+ const { amountPence, rail, donorName, donorEmail, donorPhone, donorAddressLine1, donorPostcode, giftAid, isZakat, emailOptIn, whatsappOptIn, consentMeta, eventId, qrSourceId, scheduleMode, dueDate, installmentCount, installmentDates, isConditional, conditionType, conditionText, conditionThreshold } = parsed.data
// Capture IP for consent audit trail
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
@@ -266,6 +273,13 @@ export async function POST(request: NextRequest) {
qrSourceId: qrSourceId || null,
organizationId: org.id,
dueDate: parsedDueDate,
+ // Conditional / match funding
+ isConditional: isConditional || false,
+ conditionType: isConditional ? (conditionType || "custom") : null,
+ conditionText: isConditional ? (conditionText || null) : null,
+ conditionThreshold: isConditional ? (conditionThreshold || null) : null,
+ conditionMet: false,
+ // Conditional pledges start as "new" but reminders won't fire until conditionMet
},
})
@@ -372,9 +386,75 @@ export async function POST(request: NextRequest) {
}).catch(() => {})
}
+ // ── AUTO-TRIGGER: Check if any conditional pledges' thresholds are now met ──
+ // After every new pledge, calculate total raised for this event
+ // and unlock any conditional pledges whose threshold has been reached.
+ if (prisma) {
+ checkConditionalThresholds(eventId).catch(err =>
+ console.error("[CONDITIONAL] Threshold check failed:", err)
+ )
+ }
+
return NextResponse.json(response, { status: 201 })
} catch (error) {
console.error("Pledge creation error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
+
+
+/**
+ * Check if any conditional pledges for this event have had their threshold met.
+ * If total raised (non-conditional, non-cancelled pledges) >= threshold, unlock them.
+ */
+async function checkConditionalThresholds(eventId: string) {
+ if (!prisma) return
+
+ // Get all non-cancelled, non-conditional pledge totals for this event
+ const result = await prisma.pledge.aggregate({
+ where: {
+ eventId,
+ status: { notIn: ["cancelled"] },
+ isConditional: false,
+ },
+ _sum: { amountPence: true },
+ })
+ const totalRaised = result._sum.amountPence || 0
+
+ // Find conditional pledges with threshold that's now met
+ const unmetConditionals = await prisma.pledge.findMany({
+ where: {
+ eventId,
+ isConditional: true,
+ conditionMet: false,
+ conditionThreshold: { not: null, lte: totalRaised },
+ status: { notIn: ["cancelled"] },
+ },
+ })
+
+ if (unmetConditionals.length === 0) return
+
+ // Unlock them
+ const now = new Date()
+ for (const p of unmetConditionals) {
+ await prisma.pledge.update({
+ where: { id: p.id },
+ data: { conditionMet: true, conditionMetAt: now },
+ })
+
+ // Send activation WhatsApp if they have a phone
+ if (p.donorPhone) {
+ try {
+ const { sendWhatsAppMessage } = await import("@/lib/whatsapp")
+ const name = p.donorName?.split(" ")[0] || "there"
+ const amount = (p.amountPence / 100).toFixed(0)
+ const event = await prisma.event.findUnique({ where: { id: eventId }, select: { name: true } })
+ await sendWhatsAppMessage(p.donorPhone,
+ `Assalamu Alaikum ${name} 🎉\n\nGreat news — *${event?.name || "the appeal"}* has reached its target!\n\nYour pledge of *£${amount}* is now active.\n\nReference: \`${p.reference}\`\n\nReply *HELP* for payment details.`
+ )
+ } catch { /* */ }
+ }
+
+ console.log(`[CONDITIONAL] Unlocked pledge ${p.reference} (£${(p.amountPence / 100).toFixed(0)}) — threshold £${((p.conditionThreshold || 0) / 100).toFixed(0)} met (total: £${(totalRaised / 100).toFixed(0)})`)
+ }
+}
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 1d0c101..b4bf7ac 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
@@ -33,6 +33,7 @@ export async function GET(
externalPlatform: event.externalPlatform || null,
zakatEligible: event.zakatEligible || false,
hasStripe: !!event.organization.stripeSecretKey,
+ goalAmount: event.goalAmount || null,
})
}
@@ -68,6 +69,7 @@ export async function GET(
externalPlatform: qrSource.event.externalPlatform || null,
zakatEligible: qrSource.event.zakatEligible || false,
hasStripe: !!qrSource.event.organization.stripeSecretKey,
+ goalAmount: qrSource.event.goalAmount || null,
})
} catch (error) {
console.error("QR resolve error:", error)
diff --git a/pledge-now-pay-later/src/app/dashboard/collect/page.tsx b/pledge-now-pay-later/src/app/dashboard/collect/page.tsx
index a914412..13ecadc 100644
--- a/pledge-now-pay-later/src/app/dashboard/collect/page.tsx
+++ b/pledge-now-pay-later/src/app/dashboard/collect/page.tsx
@@ -211,6 +211,8 @@ export default function CollectPage() {
// Stats
const totalPledges = events.reduce((s, e) => s + e.pledgeCount, 0)
const totalPledged = events.reduce((s, e) => s + e.totalPledged, 0)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const totalConditional = events.reduce((s, e) => s + ((e as any).totalConditional || 0), 0)
// Sort sources by pledges
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sortedSources = [...sources].sort((a: any, b: any) => b.totalPledged - a.totalPledged)
@@ -548,8 +550,14 @@ export default function CollectPage() {
{formatPence(totalPledged)}
-
raised
+
confirmed
+ {totalConditional > 0 && (
+
+
{formatPence(totalConditional)}
+
conditional
+
+ )}
diff --git a/pledge-now-pay-later/src/app/dashboard/exports/page.tsx b/pledge-now-pay-later/src/app/dashboard/exports/page.tsx
index 5ad8bcc..bd4838e 100644
--- a/pledge-now-pay-later/src/app/dashboard/exports/page.tsx
+++ b/pledge-now-pay-later/src/app/dashboard/exports/page.tsx
@@ -69,7 +69,7 @@ export default function ReportsPage() {
if (loading) return
- const s = dash?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0 }
+ const s = dash?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, totalConditionalPence: 0, conditionalCount: 0, collectionRate: 0 }
const byStatus = dash?.byStatus || {}
const outstanding = s.totalPledgedPence - s.totalCollectedPence
const giftAidPledges = (dash?.pledges || []).filter((p: { giftAid: boolean; status: string }) => p.giftAid && p.status === "paid")
@@ -111,9 +111,10 @@ export default function ReportsPage() {
{/* ── Financial breakdown ── */}
-
+
0 ? "lg:grid-cols-5" : "lg:grid-cols-4"} gap-px bg-gray-700`}>
{[
- { value: formatPence(s.totalPledgedPence), label: "Total promised", color: "text-white" },
+ { value: formatPence(s.totalPledgedPence), label: "Confirmed pledges", color: "text-white" },
+ ...(s.totalConditionalPence > 0 ? [{ value: formatPence(s.totalConditionalPence), label: `Conditional (${s.conditionalCount})`, color: "text-[#FBBF24]" }] : []),
{ value: formatPence(s.totalCollectedPence), label: "Total received", color: "text-[#4ADE80]" },
{ value: formatPence(outstanding), label: "Still outstanding", color: outstanding > 0 ? "text-[#FBBF24]" : "text-white" },
{ value: `${s.collectionRate}%`, label: "Collection rate", color: s.collectionRate >= 70 ? "text-[#4ADE80]" : "text-[#FBBF24]" },
diff --git a/pledge-now-pay-later/src/app/dashboard/page.tsx b/pledge-now-pay-later/src/app/dashboard/page.tsx
index 665a527..e2c27ae 100644
--- a/pledge-now-pay-later/src/app/dashboard/page.tsx
+++ b/pledge-now-pay-later/src/app/dashboard/page.tsx
@@ -22,9 +22,16 @@ const STATUS_LABELS: Record
{/* Stats — gap-px grid */}
-
+
0 ? "lg:grid-cols-5" : "lg:grid-cols-4"} gap-px bg-gray-200`}>
{[
{ value: String(s.totalPledges), label: "Pledges" },
- { value: formatPence(s.totalPledgedPence), label: "Promised" },
+ { value: formatPence(s.totalPledgedPence), label: "Confirmed" },
+ ...(s.totalConditionalPence > 0 ? [{ value: formatPence(s.totalConditionalPence), label: `Conditional (${s.conditionalCount})`, accent: "text-[#F59E0B]" }] : []),
{ value: formatPence(s.totalCollectedPence), label: "Received", accent: "text-[#16A34A]" },
{ value: `${s.collectionRate}%`, label: "Collected" },
].map(stat => (
@@ -292,18 +300,26 @@ export default function DashboardPage() {
))}
- {/* Progress bar */}
+ {/* Progress bar — with conditional segment */}
Promised → Received
{s.collectionRate}%
-
+
+ {s.totalConditionalPence > 0 && (
+
+ )}
{formatPence(s.totalCollectedPence)} received
{formatPence(outstanding)} still to come
+ {s.totalConditionalPence > 0 && (
+ + {formatPence(s.totalConditionalPence)} conditional
+ )}
@@ -332,8 +348,8 @@ export default function DashboardPage() {
{needsAttention.length}
- {needsAttention.map((p: { id: string; donorName: string | null; amountPence: number; eventName: string; status: string }) => {
- const sl = STATUS_LABELS[p.status] || STATUS_LABELS.new
+ {needsAttention.map((p: { id: string; donorName: string | null; amountPence: number; eventName: string; status: string; isConditional?: boolean; conditionMet?: boolean }) => {
+ const sl = getStatusLabel(p)
return (
@@ -362,8 +378,9 @@ export default function DashboardPage() {
id: string; donorName: string | null; amountPence: number; status: string;
eventName: string; createdAt: string; donorPhone: string | null;
installmentNumber: number | null; installmentTotal: number | null;
+ isConditional?: boolean; conditionMet?: boolean;
}) => {
- const sl = STATUS_LABELS[p.status] || STATUS_LABELS.new
+ const sl = getStatusLabel(p)
const initial = (p.donorName || "A")[0].toUpperCase()
const days = Math.floor((Date.now() - new Date(p.createdAt).getTime()) / 86400000)
const when = days === 0 ? "Today" : days === 1 ? "Yesterday" : days < 7 ? `${days}d ago` : new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })
@@ -479,6 +496,7 @@ export default function DashboardPage() {
{ label: "Said they paid", desc: "Donor replied PAID — upload bank statement to confirm" },
{ label: "Received ✓", desc: "Payment confirmed in your bank account" },
{ label: "Needs a nudge", desc: "It's been a while — you can send a manual reminder" },
+ { label: "🤝 Conditional", desc: "Match or conditional pledge — activates when the target is reached" },
].map(s => (
diff --git a/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx b/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx
index f483619..c344d8b 100644
--- a/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx
+++ b/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx
@@ -44,6 +44,8 @@ interface Pledge {
installmentNumber: number | null; installmentTotal: number | null
eventName: string; qrSourceLabel: string | null; volunteerName: string | null
createdAt: string; paidAt: string | null
+ // Conditional / match funding
+ isConditional: boolean; conditionText: string | null; conditionMet: boolean
}
interface MatchResult {
@@ -662,8 +664,14 @@ export default function MoneyPage() {
{p.eventName}
{p.qrSourceLabel &&
{p.qrSourceLabel}
}
-
+
{sl.label}
+ {p.isConditional && !p.conditionMet && (
+ 🤝 Conditional
+ )}
+ {p.isConditional && p.conditionMet && (
+ 🤝 Unlocked
+ )}
{timeAgo(p.createdAt)}
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 19524b9..f63d5fa 100644
--- a/pledge-now-pay-later/src/app/p/[token]/page.tsx
+++ b/pledge-now-pay-later/src/app/p/[token]/page.tsx
@@ -33,6 +33,11 @@ export interface PledgeData {
dueDate?: string
installmentCount?: number
installmentDates?: string[]
+ // Conditional / match funding
+ isConditional: boolean
+ conditionType?: "threshold" | "match" | "custom"
+ conditionText?: string
+ conditionThreshold?: number
}
interface EventInfo {
@@ -46,6 +51,7 @@ interface EventInfo {
externalPlatform: string | null
zakatEligible: boolean
hasStripe: boolean
+ goalAmount: number | null
}
/*
@@ -78,6 +84,7 @@ export default function PledgePage() {
emailOptIn: false,
whatsappOptIn: false,
scheduleMode: "now",
+ isConditional: false,
})
const [pledgeResult, setPledgeResult] = useState<{
id: string
@@ -106,14 +113,14 @@ export default function PledgePage() {
const isExternal = eventInfo?.paymentMode === "external" && eventInfo?.externalUrl
// Step 0: Amount selected
- const handleAmountSelected = (amountPence: number) => {
- setPledgeData((d) => ({ ...d, amountPence }))
+ const handleAmountSelected = (amountPence: number, conditional?: { isConditional: boolean; conditionType?: "threshold" | "match" | "custom"; conditionText?: string; conditionThreshold?: number }) => {
+ const conditionalData = conditional || { isConditional: false }
+ setPledgeData((d) => ({ ...d, amountPence, ...conditionalData }))
if (isExternal) {
- // External events: amount → identity → redirect (skip schedule + payment method)
- setPledgeData((d) => ({ ...d, amountPence, rail: "bank", scheduleMode: "now" }))
- setStep(3) // → Identity
+ setPledgeData((d) => ({ ...d, amountPence, rail: "bank", scheduleMode: "now", ...conditionalData }))
+ setStep(3)
} else {
- setStep(1) // → Schedule step
+ setStep(1)
}
}
@@ -225,7 +232,7 @@ export default function PledgePage() {
: undefined
const steps: Record
= {
- 0: ,
+ 0: ,
1: ,
2: ,
3: ,
@@ -242,6 +249,8 @@ export default function PledgePage() {
dueDateLabel={dueDateLabel}
installmentCount={pledgeData.installmentCount}
installmentAmount={pledgeData.installmentCount ? Math.ceil(pledgeData.amountPence / pledgeData.installmentCount) : undefined}
+ isConditional={pledgeData.isConditional}
+ conditionText={pledgeData.conditionText}
/>
),
6: ,
diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/amount-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/amount-step.tsx
index 2f362e1..f75e703 100644
--- a/pledge-now-pay-later/src/app/p/[token]/steps/amount-step.tsx
+++ b/pledge-now-pay-later/src/app/p/[token]/steps/amount-step.tsx
@@ -5,9 +5,10 @@ import { Button } from "@/components/ui/button"
import { Heart, Sparkles, TrendingUp } from "lucide-react"
interface Props {
- onSelect: (amountPence: number) => void
+ onSelect: (amountPence: number, conditional?: { isConditional: boolean; conditionType?: "threshold" | "match" | "custom"; conditionText?: string; conditionThreshold?: number }) => void
eventName: string
eventId?: string
+ goalAmount?: number | null
}
interface AiSuggestion {
@@ -18,13 +19,18 @@ interface AiSuggestion {
const FALLBACK_AMOUNTS = [2000, 5000, 10000, 25000, 50000, 100000]
-export function AmountStep({ onSelect, eventName, eventId }: Props) {
+export function AmountStep({ onSelect, eventName, eventId, goalAmount }: Props) {
const [custom, setCustom] = useState("")
const [selected, setSelected] = useState(null)
const [suggestions, setSuggestions] = useState(null)
const [hovering, setHovering] = useState(null)
const inputRef = useRef(null)
+ // Conditional / match funding
+ const [isMatch, setIsMatch] = useState(false)
+ const [matchType, setMatchType] = useState<"threshold" | "match">("threshold")
+ const [matchThreshold, setMatchThreshold] = useState("")
+
// Fetch AI-powered suggestions
useEffect(() => {
const url = eventId ? `/api/ai/suggest?eventId=${eventId}` : "/api/ai/suggest"
@@ -53,7 +59,22 @@ export function AmountStep({ onSelect, eventName, eventId }: Props) {
const handleContinue = () => {
const amount = selected || Math.round(parseFloat(custom) * 100)
- if (amount >= 100) onSelect(amount)
+ if (amount >= 100) {
+ if (isMatch) {
+ const thresholdPence = matchThreshold ? Math.round(parseFloat(matchThreshold) * 100) : (goalAmount || 0)
+ const conditionText = matchType === "match"
+ ? `I'll match up to £${(amount / 100).toFixed(0)}`
+ : `I'll give £${(amount / 100).toFixed(0)} if £${(thresholdPence / 100).toFixed(0)} is raised`
+ onSelect(amount, {
+ isConditional: true,
+ conditionType: matchType,
+ conditionText,
+ conditionThreshold: thresholdPence,
+ })
+ } else {
+ onSelect(amount)
+ }
+ }
}
const activeAmount = selected || (custom ? Math.round(parseFloat(custom) * 100) : 0)
@@ -176,6 +197,78 @@ export function AmountStep({ onSelect, eventName, eventId }: Props) {
)}
+ {/* Match / conditional pledge */}
+ {isValid && (
+
+
setIsMatch(!isMatch)}
+ className={`w-full text-left rounded-lg border-2 p-4 transition-all ${
+ isMatch ? "border-trust-blue bg-trust-blue/5" : "border-gray-100 hover:border-gray-200"
+ }`}
+ >
+
+
🤝
+
+
This is a match pledge
+
+ I'll give this amount only if a condition is met
+
+
+
+
+
+ {isMatch && (
+
+
+ setMatchType("threshold")}
+ className={`p-3 text-center rounded-lg border-2 text-xs font-bold transition-all ${
+ matchType === "threshold" ? "border-trust-blue bg-trust-blue/5 text-trust-blue" : "border-gray-200 text-gray-500"
+ }`}
+ >
+ If target is reached
+
+ setMatchType("match")}
+ className={`p-3 text-center rounded-lg border-2 text-xs font-bold transition-all ${
+ matchType === "match" ? "border-trust-blue bg-trust-blue/5 text-trust-blue" : "border-gray-200 text-gray-500"
+ }`}
+ >
+ Match funding
+
+
+
+ {matchType === "threshold" && (
+
+
+ I'll give £{(activeAmount / 100).toFixed(0)} if the appeal raises:
+
+
+ £
+ setMatchThreshold(e.target.value.replace(/[^0-9]/g, ""))}
+ placeholder={goalAmount ? (goalAmount / 100).toFixed(0) : "10000"}
+ className="w-full pl-8 pr-3 h-10 rounded-lg border-2 border-gray-200 text-sm font-bold focus:border-trust-blue outline-none"
+ />
+
+
+ )}
+
+ {matchType === "match" && (
+
+
+ You'll match other donors' pledges, up to £{(activeAmount / 100).toFixed(0)} .
+ Your pledge activates when the appeal reaches £{(activeAmount / 100).toFixed(0)} from other donors.
+
+
+ )}
+
+ )}
+
+ )}
+
{/* Continue */}
- {isDeferred
- ? "Pledge Locked In!"
- : rail === "card" ? "Payment Complete!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
+ {isConditional
+ ? "Match Pledge Registered!"
+ : isDeferred
+ ? "Pledge Locked In!"
+ : rail === "card" ? "Payment Complete!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
- Thank you for your generous support of{" "}
- {eventName}
+ {isConditional ? (
+ <>
+ Your match pledge of{" "}
+ £{(amount / 100).toFixed(0)} {" "}
+ for {eventName} has been registered.
+ {conditionText && {conditionText} }
+ We'll notify you when the condition is met and your pledge activates.
+ >
+ ) : (
+ <>
+ Thank you for your generous support of{" "}
+ {eventName}
+ >
+ )}
diff --git a/pledge-now-pay-later/src/app/p/my-pledges/page.tsx b/pledge-now-pay-later/src/app/p/my-pledges/page.tsx
index 6739a82..9217ec6 100644
--- a/pledge-now-pay-later/src/app/p/my-pledges/page.tsx
+++ b/pledge-now-pay-later/src/app/p/my-pledges/page.tsx
@@ -11,6 +11,7 @@ function MyPledgesForm() {
createdAt: string; paidAt: string | null; dueDate: string | null;
installmentNumber: number | null; installmentTotal: number | null;
bankDetails?: { sortCode: string; accountNo: string; accountName: string };
+ isConditional?: boolean; conditionText?: string; conditionMet?: boolean;
}> | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
@@ -110,6 +111,16 @@ function MyPledgesForm() {
{p.installmentNumber &&
Instalment: {p.installmentNumber} of {p.installmentTotal}
}
{p.dueDate &&
Due: {new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}
}
{p.paidAt &&
Paid: {new Date(p.paidAt).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}
}
+ {p.isConditional && !p.conditionMet && (
+
+
🤝 Conditional pledge
+ {p.conditionText &&
{p.conditionText}
}
+
Payment details will be sent when the condition is met
+
+ )}
+ {p.isConditional && p.conditionMet && (
+
🤝 Condition met — pledge activated!
+ )}
{p.status !== "paid" && p.status !== "cancelled" && p.bankDetails && (
diff --git a/pledge-now-pay-later/src/lib/validators.ts b/pledge-now-pay-later/src/lib/validators.ts
index f800826..1461633 100644
--- a/pledge-now-pay-later/src/lib/validators.ts
+++ b/pledge-now-pay-later/src/lib/validators.ts
@@ -20,7 +20,12 @@ export const createQrSourceSchema = z.object({
})
export const createPledgeSchema = z.object({
- amountPence: z.number().int().min(100).max(100000000), // £1 to £1M
+ amountPence: z.number().int().min(100).max(100000000),
+ // Conditional / match funding
+ isConditional: z.boolean().default(false),
+ conditionType: z.enum(["threshold", "match", "custom"]).optional(),
+ conditionText: z.string().max(500).optional(),
+ conditionThreshold: z.number().int().positive().optional(),
rail: z.enum(['bank', 'gocardless', 'card']),
donorName: z.string().max(200).optional().default(''),
donorEmail: z.string().max(200).optional().default(''),
diff --git a/temp_files/fix2/ListDonations.php b/temp_files/fix2/ListDonations.php
new file mode 100644
index 0000000..2c800d7
--- /dev/null
+++ b/temp_files/fix2/ListDonations.php
@@ -0,0 +1,110 @@
+ $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();
+
+ $recurring = Donation::where('reoccurrence', '!=', -1)
+ ->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
+ ->count();
+
+ // Use whereIn with subquery instead of whereHas to avoid null model crash
+ // during Filament tab initialization (modifyQueryUsing gets a builder with no model)
+ $confirmedSubquery = fn (Builder $q) => $q->whereIn(
+ 'donations.id',
+ fn ($sub) => $sub->select('donation_id')
+ ->from('donation_confirmations')
+ ->whereNotNull('confirmed_at')
+ );
+
+ $unconfirmedSubquery = fn (Builder $q) => $q->whereNotIn(
+ 'donations.id',
+ fn ($sub) => $sub->select('donation_id')
+ ->from('donation_confirmations')
+ ->whereNotNull('confirmed_at')
+ );
+
+ return [
+ 'today' => Tab::make('Today')
+ ->icon('heroicon-o-clock')
+ ->modifyQueryUsing(fn (Builder $q) => $confirmedSubquery($q)
+ ->whereDate('created_at', today())
+ ),
+
+ 'all_confirmed' => Tab::make('All Confirmed')
+ ->icon('heroicon-o-check-circle')
+ ->modifyQueryUsing(fn (Builder $q) => $confirmedSubquery($q)),
+
+ 'incomplete' => Tab::make('Incomplete')
+ ->icon('heroicon-o-exclamation-triangle')
+ ->badge($incompleteCount > 0 ? $incompleteCount : null)
+ ->badgeColor('danger')
+ ->modifyQueryUsing(fn (Builder $q) => $unconfirmedSubquery($q)
+ ->where('created_at', '>=', now()->subDays(7))
+ ),
+
+ 'zakat' => Tab::make('Zakat')
+ ->icon('heroicon-o-star')
+ ->modifyQueryUsing(fn (Builder $q) => $confirmedSubquery($q)
+ ->whereIn('donations.id', fn ($sub) => $sub->select('donation_id')
+ ->from('donation_preferences')
+ ->where('is_zakat', true))
+ ),
+
+ 'gift_aid' => Tab::make('Gift Aid')
+ ->icon('heroicon-o-gift')
+ ->modifyQueryUsing(fn (Builder $q) => $confirmedSubquery($q)
+ ->whereIn('donations.id', fn ($sub) => $sub->select('donation_id')
+ ->from('donation_preferences')
+ ->where('is_gift_aid', true))
+ ),
+
+ 'recurring' => Tab::make('Recurring')
+ ->icon('heroicon-o-arrow-path')
+ ->badge($recurring > 0 ? $recurring : null)
+ ->badgeColor('info')
+ ->modifyQueryUsing(fn (Builder $q) => $confirmedSubquery($q)
+ ->where('reoccurrence', '!=', -1)
+ ),
+
+ 'everything' => Tab::make('Everything')
+ ->icon('heroicon-o-squares-2x2'),
+ ];
+ }
+
+ public function getDefaultActiveTab(): string | int | null
+ {
+ return 'today';
+ }
+}
diff --git a/temp_files/fix2/ListScheduledGivingDonations.php b/temp_files/fix2/ListScheduledGivingDonations.php
index 6415c4e..b0eaea2 100644
--- a/temp_files/fix2/ListScheduledGivingDonations.php
+++ b/temp_files/fix2/ListScheduledGivingDonations.php
@@ -20,88 +20,85 @@ class ListScheduledGivingDonations extends ListRecords
public function getSubheading(): string
{
- $current = $this->currentSeasonScope()->count();
+ $current = $this->currentSeasonCount();
return "{$current} subscribers this season.";
}
- /** Real subscriber: has customer, has payments, amount > 0, not soft-deleted */
- private function realScope(): Builder
+ /** Count real current-season subscribers using the model directly (safe) */
+ private function currentSeasonCount(): int
{
return ScheduledGivingDonation::query()
->whereNotNull('customer_id')
->where('total_amount', '>', 0)
- ->whereNull('scheduled_giving_donations.deleted_at')
- ->whereHas('payments', fn ($q) => $q->whereNull('deleted_at'));
+ ->whereNull('deleted_at')
+ ->whereHas('payments', fn ($q) => $q->whereNull('deleted_at'))
+ ->whereHas('payments', fn ($q) => $q->whereNull('deleted_at')->where('expected_at', '>', now()))
+ ->count();
}
- /** Current season: real + has at least one future payment */
- private function currentSeasonScope(): Builder
- {
- return $this->realScope()
- ->whereHas('payments', fn ($q) => $q
- ->whereNull('deleted_at')
- ->where('expected_at', '>', now()));
- }
-
- /** Applies real + current season filters to the query */
- private function applyCurrentSeason(Builder $q): Builder
- {
- return $q
- ->whereNotNull('customer_id')
- ->where('total_amount', '>', 0)
- ->whereNull('scheduled_giving_donations.deleted_at')
- ->whereHas('payments', fn ($sub) => $sub->whereNull('deleted_at'))
- ->whereHas('payments', fn ($sub) => $sub
- ->whereNull('deleted_at')
- ->where('expected_at', '>', now()));
- }
-
- /** Applies real + expired (no future payments) filters */
- private function applyExpired(Builder $q): Builder
- {
- return $q
- ->whereNotNull('customer_id')
- ->where('total_amount', '>', 0)
- ->whereNull('scheduled_giving_donations.deleted_at')
- ->whereHas('payments', fn ($sub) => $sub->whereNull('deleted_at'))
- ->whereDoesntHave('payments', fn ($sub) => $sub
- ->whereNull('deleted_at')
- ->where('expected_at', '>', now()));
- }
-
- /** Applies real subscriber filters */
+ /**
+ * Apply "real subscriber" filter using whereIn subqueries
+ * instead of whereHas — avoids null model crash during tab init.
+ */
private function applyReal(Builder $q): Builder
{
return $q
->whereNotNull('customer_id')
->where('total_amount', '>', 0)
->whereNull('scheduled_giving_donations.deleted_at')
- ->whereHas('payments', fn ($sub) => $sub->whereNull('deleted_at'));
+ ->whereIn('scheduled_giving_donations.id', fn ($sub) => $sub
+ ->select('scheduled_giving_donation_id')
+ ->from('scheduled_giving_payments')
+ ->whereNull('deleted_at'));
+ }
+
+ /** Real + has future payment = current season */
+ private function applyCurrentSeason(Builder $q): Builder
+ {
+ return $this->applyReal($q)
+ ->whereIn('scheduled_giving_donations.id', fn ($sub) => $sub
+ ->select('scheduled_giving_donation_id')
+ ->from('scheduled_giving_payments')
+ ->whereNull('deleted_at')
+ ->where('expected_at', '>', now()));
+ }
+
+ /** Real + NO future payments = expired */
+ private function applyExpired(Builder $q): Builder
+ {
+ return $this->applyReal($q)
+ ->whereNotIn('scheduled_giving_donations.id', fn ($sub) => $sub
+ ->select('scheduled_giving_donation_id')
+ ->from('scheduled_giving_payments')
+ ->whereNull('deleted_at')
+ ->where('expected_at', '>', now()));
}
public function getTabs(): array
{
$campaigns = ScheduledGivingCampaign::all();
-
- $currentCount = $this->currentSeasonScope()->count();
+ $currentCount = $this->currentSeasonCount();
$tabs = [];
- // Current season — the primary tab
$tabs['current'] = Tab::make('This Season')
->icon('heroicon-o-sun')
->badge($currentCount)
->badgeColor('success')
->modifyQueryUsing(fn (Builder $q) => $this->applyCurrentSeason($q));
- // Per-campaign tabs for current season
foreach ($campaigns as $c) {
$slug = str($c->title)->slug()->toString();
- $count = $this->currentSeasonScope()
+ $count = ScheduledGivingDonation::query()
+ ->whereNotNull('customer_id')
+ ->where('total_amount', '>', 0)
+ ->whereNull('deleted_at')
->where('scheduled_giving_campaign_id', $c->id)
+ ->whereHas('payments', fn ($q) => $q->whereNull('deleted_at'))
+ ->whereHas('payments', fn ($q) => $q->whereNull('deleted_at')->where('expected_at', '>', now()))
->count();
- if ($count === 0) continue; // Skip campaigns with no current subscribers
+ if ($count === 0) continue;
$tabs[$slug] = Tab::make($c->title)
->icon('heroicon-o-calendar')
@@ -111,12 +108,14 @@ class ListScheduledGivingDonations extends ListRecords
->where('scheduled_giving_campaign_id', $c->id));
}
- // Failed (current season only)
- $failedCount = $this->currentSeasonScope()
- ->whereHas('payments', fn ($q) => $q
- ->where('is_paid', false)
- ->where('attempts', '>', 0)
- ->whereNull('deleted_at'))
+ // Failed (current season)
+ $failedCount = ScheduledGivingDonation::query()
+ ->whereNotNull('customer_id')
+ ->where('total_amount', '>', 0)
+ ->whereNull('deleted_at')
+ ->whereHas('payments', fn ($q) => $q->whereNull('deleted_at'))
+ ->whereHas('payments', fn ($q) => $q->whereNull('deleted_at')->where('expected_at', '>', now()))
+ ->whereHas('payments', fn ($q) => $q->where('is_paid', false)->where('attempts', '>', 0)->whereNull('deleted_at'))
->count();
if ($failedCount > 0) {
@@ -125,17 +124,21 @@ class ListScheduledGivingDonations extends ListRecords
->badge($failedCount)
->badgeColor('danger')
->modifyQueryUsing(fn (Builder $q) => $this->applyCurrentSeason($q)
- ->whereHas('payments', fn ($sub) => $sub
+ ->whereIn('scheduled_giving_donations.id', fn ($sub) => $sub
+ ->select('scheduled_giving_donation_id')
+ ->from('scheduled_giving_payments')
->where('is_paid', false)
->where('attempts', '>', 0)
->whereNull('deleted_at')));
}
// Past seasons
- $expiredCount = $this->realScope()
- ->whereDoesntHave('payments', fn ($q) => $q
- ->whereNull('deleted_at')
- ->where('expected_at', '>', now()))
+ $expiredCount = ScheduledGivingDonation::query()
+ ->whereNotNull('customer_id')
+ ->where('total_amount', '>', 0)
+ ->whereNull('deleted_at')
+ ->whereHas('payments', fn ($q) => $q->whereNull('deleted_at'))
+ ->whereDoesntHave('payments', fn ($q) => $q->whereNull('deleted_at')->where('expected_at', '>', now()))
->count();
$tabs['past'] = Tab::make('Past Seasons')
@@ -144,7 +147,6 @@ class ListScheduledGivingDonations extends ListRecords
->badgeColor('gray')
->modifyQueryUsing(fn (Builder $q) => $this->applyExpired($q));
- // All real
$tabs['all'] = Tab::make('All')
->icon('heroicon-o-squares-2x2')
->modifyQueryUsing(fn (Builder $q) => $this->applyReal($q));
diff --git a/temp_files/fix2/ScheduledGivingDashboard.php b/temp_files/fix2/ScheduledGivingDashboard.php
index 7616da5..3deb45a 100644
--- a/temp_files/fix2/ScheduledGivingDashboard.php
+++ b/temp_files/fix2/ScheduledGivingDashboard.php
@@ -82,7 +82,7 @@ class ScheduledGivingDashboard extends Page
$currentIds = $this->currentSeasonIds($c->id);
$expiredIds = $realIds->diff($currentIds);
- // Current season payment stats
+ // Current season payment stats — separate due vs future
$currentPayments = null;
if ($currentIds->isNotEmpty()) {
$currentPayments = DB::table('scheduled_giving_payments')
@@ -91,10 +91,12 @@ class ScheduledGivingDashboard extends Page
->selectRaw("
COUNT(*) as total,
SUM(is_paid = 1) as paid,
- SUM(is_paid = 0) as pending,
- SUM(is_paid = 0 AND attempts > 0) as failed,
+ SUM(is_paid = 0 AND expected_at <= NOW()) as failed,
+ SUM(is_paid = 0 AND expected_at > NOW()) as scheduled,
+ SUM(expected_at <= NOW()) as due,
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) as collected,
- SUM(CASE WHEN is_paid = 0 THEN amount ELSE 0 END) as pending_amount,
+ SUM(CASE WHEN is_paid = 0 AND expected_at <= NOW() THEN amount ELSE 0 END) as failed_amount,
+ SUM(CASE WHEN is_paid = 0 AND expected_at > NOW() THEN amount ELSE 0 END) as scheduled_amount,
AVG(CASE WHEN is_paid = 1 THEN amount ELSE NULL END) as avg_amount,
MIN(CASE WHEN is_paid = 0 AND expected_at > NOW() THEN expected_at ELSE NULL END) as next_payment
")
@@ -121,6 +123,9 @@ class ScheduledGivingDashboard extends Page
$fullyPaid = $row->cnt ?? 0;
}
+ $due = (int) ($currentPayments->due ?? 0);
+ $paid = (int) ($currentPayments->paid ?? 0);
+
$result[] = [
'campaign' => $c,
'all_time_subscribers' => $realIds->count(),
@@ -130,13 +135,16 @@ class ScheduledGivingDashboard extends Page
// Current season
'current_subscribers' => $currentIds->count(),
'expired_subscribers' => $expiredIds->count(),
- 'current_payments' => (int) ($currentPayments->total ?? 0),
- 'current_paid' => (int) ($currentPayments->paid ?? 0),
- 'current_pending' => (int) ($currentPayments->pending ?? 0),
- 'current_failed' => (int) ($currentPayments->failed ?? 0),
- 'current_collected' => ($currentPayments->collected ?? 0) / 100,
- 'current_pending_amount' => ($currentPayments->pending_amount ?? 0) / 100,
+ 'total_payments' => (int) ($currentPayments->total ?? 0),
+ 'due_payments' => $due,
+ 'paid_payments' => $paid,
+ 'failed_payments' => (int) ($currentPayments->failed ?? 0),
+ 'scheduled_payments' => (int) ($currentPayments->scheduled ?? 0),
+ 'collected' => ($currentPayments->collected ?? 0) / 100,
+ 'failed_amount' => ($currentPayments->failed_amount ?? 0) / 100,
+ 'scheduled_amount' => ($currentPayments->scheduled_amount ?? 0) / 100,
'avg_per_night' => ($currentPayments->avg_amount ?? 0) / 100,
+ 'collection_rate' => $due > 0 ? round($paid / $due * 100, 1) : 0,
'fully_completed' => $fullyPaid,
'dates' => $c->dates ?? [],
'total_nights' => $totalNights,
@@ -169,25 +177,32 @@ class ScheduledGivingDashboard extends Page
->whereNull('deleted_at')
->selectRaw("
SUM(is_paid = 1) as paid,
- SUM(is_paid = 0 AND attempts > 0) as failed,
+ SUM(expected_at <= NOW()) as due,
+ SUM(is_paid = 0 AND expected_at <= NOW()) as failed,
+ SUM(is_paid = 0 AND expected_at > NOW()) as scheduled,
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) / 100 as collected,
- SUM(CASE WHEN is_paid = 0 THEN amount ELSE 0 END) / 100 as pending,
- COUNT(*) as total
+ SUM(CASE WHEN is_paid = 0 AND expected_at <= NOW() THEN amount ELSE 0 END) / 100 as failed_amount,
+ SUM(CASE WHEN is_paid = 0 AND expected_at > NOW() THEN amount ELSE 0 END) / 100 as scheduled_amount
")
->first();
}
+ $due = (int) ($currentStats->due ?? 0);
+ $paid = (int) ($currentStats->paid ?? 0);
+
return [
'total_subscribers' => $realIds->count(),
'current_subscribers' => $currentIds->count(),
'expired_subscribers' => $realIds->count() - $currentIds->count(),
'all_time_collected' => (float) ($allTime->collected ?? 0),
- 'current_collected' => (float) ($currentStats->collected ?? 0),
- 'current_pending' => (float) ($currentStats->pending ?? 0),
- 'current_failed' => (int) ($currentStats->failed ?? 0),
- 'collection_rate' => ($currentStats->total ?? 0) > 0
- ? round($currentStats->paid / $currentStats->total * 100, 1)
- : 0,
+ 'collected' => (float) ($currentStats->collected ?? 0),
+ 'failed_amount' => (float) ($currentStats->failed_amount ?? 0),
+ 'scheduled_amount' => (float) ($currentStats->scheduled_amount ?? 0),
+ 'failed_count' => (int) ($currentStats->failed ?? 0),
+ 'scheduled_count' => (int) ($currentStats->scheduled ?? 0),
+ 'due_payments' => $due,
+ 'paid_payments' => $paid,
+ 'collection_rate' => $due > 0 ? round($paid / $due * 100, 1) : 0,
];
}
diff --git a/temp_files/fix2/scheduled-giving-dashboard.blade.php b/temp_files/fix2/scheduled-giving-dashboard.blade.php
index 758181e..8fe1133 100644
--- a/temp_files/fix2/scheduled-giving-dashboard.blade.php
+++ b/temp_files/fix2/scheduled-giving-dashboard.blade.php
@@ -16,29 +16,33 @@
-
+
{{ number_format($global['current_subscribers']) }}
-
Active This Season
-
{{ number_format($global['expired_subscribers']) }} from past seasons
+
Active
+
{{ number_format($global['expired_subscribers']) }} past seasons
-
£{{ number_format($global['current_collected'], 0) }}
-
Collected This Season
-
£{{ number_format($global['all_time_collected'], 0) }} all-time
+
£{{ number_format($global['collected'], 0) }}
+
Collected
+
{{ number_format($global['paid_payments']) }}/{{ number_format($global['due_payments']) }} due paid
-
£{{ number_format($global['current_pending'], 0) }}
-
Pending
- @if ($global['current_failed'] > 0)
-
{{ $global['current_failed'] }} failed
- @endif
+
£{{ number_format($global['failed_amount'], 0) }}
+
Failed
+
{{ number_format($global['failed_count']) }} payments
+
+
+
£{{ number_format($global['scheduled_amount'], 0) }}
+
Upcoming
+
{{ number_format($global['scheduled_count']) }} not yet due
{{ $global['collection_rate'] }}%
Collection Rate
+
of due payments
@@ -49,8 +53,11 @@
@php
$c = $data['campaign'];
$hasCurrent = $data['current_subscribers'] > 0;
- $progressPct = $data['current_payments'] > 0
- ? round($data['current_paid'] / $data['current_payments'] * 100)
+ $duePct = $data['due_payments'] > 0
+ ? round($data['paid_payments'] / $data['due_payments'] * 100)
+ : 0;
+ $overallPct = $data['total_payments'] > 0
+ ? round($data['paid_payments'] / $data['total_payments'] * 100)
: 0;
@endphp
@@ -67,8 +74,6 @@
@if ($hasCurrent)
- {{-- Current Season --}}
-
This Season
Subscribers
@@ -80,23 +85,35 @@
Collected
-
£{{ number_format($data['current_collected'], 0) }}
+
£{{ number_format($data['collected'], 0) }}
-
Pending
-
£{{ number_format($data['current_pending_amount'], 0) }}
+
+ {{ $data['collection_rate'] }}% Rate
+
+
+ {{ $data['paid_payments'] }}/{{ $data['due_payments'] }} due
+
- {{-- Payment progress bar --}}
+ {{-- Progress bar: paid / total (including future) --}}
- {{ number_format($data['current_paid']) }} / {{ number_format($data['current_payments']) }} payments
- {{ $progressPct }}%
+ {{ number_format($data['paid_payments']) }} paid, {{ number_format($data['failed_payments']) }} failed, {{ number_format($data['scheduled_payments']) }} upcoming
-
-
+
+ @if ($data['total_payments'] > 0)
+
+
+ @endif
+
+
+ ■ Paid
+ @if ($data['failed_payments'] > 0)
+ ■ Failed (£{{ number_format($data['failed_amount'], 0) }})
+ @endif
+ ■ Upcoming (£{{ number_format($data['scheduled_amount'], 0) }})
@@ -111,12 +128,12 @@
Failed
-
{{ $data['current_failed'] }}
+
{{ $data['failed_payments'] }}
@endif
- {{-- All-time summary --}}
+ {{-- All-time --}}
All Time
@@ -220,7 +237,6 @@
{{ number_format($quality['total_records']) }} total records in database.
Only {{ number_format($global['total_subscribers']) }} are real subscribers with payments.
- The rest are incomplete sign-ups, test data, or soft-deleted.