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 && ( +
+ + + {isMatch && ( +
+
+ + +
+ + {matchType === "threshold" && ( +
+ +
+ £ + 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 */}
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.