feat: conditional & match funding pledges — deeply integrated across entire product

- Schema: isConditional, conditionType, conditionText, conditionThreshold, conditionMet, conditionMetAt on Pledge
- Pledge form: 'This is a match pledge' toggle after amount selection
  - Two modes: threshold (if target is reached) and match (match funding)
  - Goal amount passed through from event
- Auto-trigger: when total raised hits threshold, conditional pledges unlock automatically
  - WhatsApp notification sent to donor when unlocked
  - Threshold check runs after every pledge creation AND every status change
- Cron: skips conditional pledges until conditionMet=true (no premature reminders)
- Dashboard Home: progress bar shows conditional segment (amber), stats grid adds Conditional column
- Dashboard Money: conditional/unlocked badge on pledge rows
- Dashboard Collect: hero shows conditional total in amber
- Dashboard Reports: financial summary shows conditional breakdown
- Donor 'My Pledges': conditional card with condition text + activation status
- Confirmation step: specialized messaging for match pledges
- CRM export: includes is_conditional, condition_type, condition_text, condition_met columns
- Status guide: conditional status explained in human language
This commit is contained in:
2026-03-05 04:19:23 +08:00
parent c11bf4bea7
commit 50d449e2b7
23 changed files with 607 additions and 140 deletions

View File

@@ -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,
];
}