- 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
304 lines
13 KiB
PHP
304 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Pages;
|
|
|
|
use App\Models\ScheduledGivingCampaign;
|
|
use App\Models\ScheduledGivingDonation;
|
|
use Filament\Pages\Page;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Scheduled Giving Command Centre.
|
|
*
|
|
* Campaigns are seasonal (Ramadan). Each subscription belongs to a specific year.
|
|
* "Current season" = has future payments. "Expired" = all payments in the past.
|
|
* Only shows REAL subscribers (has customer + payments + amount > 0 + not soft-deleted).
|
|
*/
|
|
class ScheduledGivingDashboard extends Page
|
|
{
|
|
protected static ?string $navigationIcon = 'heroicon-o-calendar-days';
|
|
|
|
protected static ?string $navigationGroup = 'Giving';
|
|
|
|
protected static ?int $navigationSort = 0;
|
|
|
|
protected static ?string $navigationLabel = 'Dashboard';
|
|
|
|
protected static ?string $title = 'Scheduled Giving';
|
|
|
|
protected static string $view = 'filament.pages.scheduled-giving-dashboard';
|
|
|
|
/** Real subscriber IDs (has customer + payments + amount > 0 + not soft-deleted) */
|
|
private function realSubscriberIds(?int $campaignId = null)
|
|
{
|
|
$q = DB::table('scheduled_giving_donations as d')
|
|
->whereNotNull('d.customer_id')
|
|
->where('d.total_amount', '>', 0)
|
|
->whereNull('d.deleted_at')
|
|
->whereExists(function ($sub) {
|
|
$sub->select(DB::raw(1))
|
|
->from('scheduled_giving_payments as p')
|
|
->whereColumn('p.scheduled_giving_donation_id', 'd.id')
|
|
->whereNull('p.deleted_at');
|
|
});
|
|
|
|
if ($campaignId) {
|
|
$q->where('d.scheduled_giving_campaign_id', $campaignId);
|
|
}
|
|
|
|
return $q->pluck('d.id');
|
|
}
|
|
|
|
/** IDs with at least one future payment = current season */
|
|
private function currentSeasonIds(?int $campaignId = null)
|
|
{
|
|
$realIds = $this->realSubscriberIds($campaignId);
|
|
if ($realIds->isEmpty()) return collect();
|
|
|
|
return DB::table('scheduled_giving_donations as d')
|
|
->whereIn('d.id', $realIds)
|
|
->whereExists(function ($sub) {
|
|
$sub->select(DB::raw(1))
|
|
->from('scheduled_giving_payments as p')
|
|
->whereColumn('p.scheduled_giving_donation_id', 'd.id')
|
|
->whereNull('p.deleted_at')
|
|
->whereRaw('p.expected_at > NOW()');
|
|
})
|
|
->pluck('d.id');
|
|
}
|
|
|
|
public function getCampaignData(): array
|
|
{
|
|
$campaigns = ScheduledGivingCampaign::all();
|
|
$result = [];
|
|
|
|
foreach ($campaigns as $c) {
|
|
$realIds = $this->realSubscriberIds($c->id);
|
|
if ($realIds->isEmpty()) {
|
|
$result[] = $this->emptyCampaign($c);
|
|
continue;
|
|
}
|
|
|
|
$currentIds = $this->currentSeasonIds($c->id);
|
|
$expiredIds = $realIds->diff($currentIds);
|
|
|
|
// Current season payment stats — separate due vs future
|
|
$currentPayments = null;
|
|
if ($currentIds->isNotEmpty()) {
|
|
$currentPayments = DB::table('scheduled_giving_payments')
|
|
->whereIn('scheduled_giving_donation_id', $currentIds)
|
|
->whereNull('deleted_at')
|
|
->selectRaw("
|
|
COUNT(*) as total,
|
|
SUM(is_paid = 1) as paid,
|
|
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 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
|
|
")
|
|
->first();
|
|
}
|
|
|
|
// All-time payment stats (for totals)
|
|
$allPayments = DB::table('scheduled_giving_payments')
|
|
->whereIn('scheduled_giving_donation_id', $realIds)
|
|
->whereNull('deleted_at')
|
|
->selectRaw("
|
|
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) as collected,
|
|
SUM(CASE WHEN is_paid = 1 THEN 1 ELSE 0 END) as paid,
|
|
COUNT(*) as total
|
|
")
|
|
->first();
|
|
|
|
// Completion for current season
|
|
$totalNights = count($c->dates ?? []);
|
|
$fullyPaid = 0;
|
|
if ($totalNights > 0 && $currentIds->isNotEmpty()) {
|
|
$ids = $currentIds->implode(',');
|
|
$row = DB::selectOne("SELECT COUNT(*) as cnt FROM (SELECT scheduled_giving_donation_id FROM scheduled_giving_payments WHERE scheduled_giving_donation_id IN ({$ids}) AND deleted_at IS NULL GROUP BY scheduled_giving_donation_id HAVING SUM(is_paid) >= {$totalNights}) sub");
|
|
$fullyPaid = $row->cnt ?? 0;
|
|
}
|
|
|
|
$due = (int) ($currentPayments->due ?? 0);
|
|
$paid = (int) ($currentPayments->paid ?? 0);
|
|
|
|
$result[] = [
|
|
'campaign' => $c,
|
|
'all_time_subscribers' => $realIds->count(),
|
|
'all_time_collected' => ($allPayments->collected ?? 0) / 100,
|
|
'all_time_payments' => (int) ($allPayments->total ?? 0),
|
|
'all_time_paid' => (int) ($allPayments->paid ?? 0),
|
|
// Current season
|
|
'current_subscribers' => $currentIds->count(),
|
|
'expired_subscribers' => $expiredIds->count(),
|
|
'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,
|
|
'next_payment' => $currentPayments->next_payment ?? null,
|
|
];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function getGlobalStats(): array
|
|
{
|
|
$realIds = $this->realSubscriberIds();
|
|
$currentIds = $this->currentSeasonIds();
|
|
|
|
$allTime = DB::table('scheduled_giving_payments')
|
|
->whereIn('scheduled_giving_donation_id', $realIds)
|
|
->whereNull('deleted_at')
|
|
->selectRaw("
|
|
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) / 100 as collected,
|
|
SUM(CASE WHEN is_paid = 1 THEN 1 ELSE 0 END) as paid,
|
|
COUNT(*) as total
|
|
")
|
|
->first();
|
|
|
|
$currentStats = null;
|
|
if ($currentIds->isNotEmpty()) {
|
|
$currentStats = DB::table('scheduled_giving_payments')
|
|
->whereIn('scheduled_giving_donation_id', $currentIds)
|
|
->whereNull('deleted_at')
|
|
->selectRaw("
|
|
SUM(is_paid = 1) as paid,
|
|
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 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),
|
|
'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,
|
|
];
|
|
}
|
|
|
|
public function getFailedPayments(): array
|
|
{
|
|
$currentIds = $this->currentSeasonIds();
|
|
if ($currentIds->isEmpty()) return [];
|
|
|
|
return DB::table('scheduled_giving_payments as p')
|
|
->join('scheduled_giving_donations as d', 'd.id', '=', 'p.scheduled_giving_donation_id')
|
|
->join('scheduled_giving_campaigns as c', 'c.id', '=', 'd.scheduled_giving_campaign_id')
|
|
->leftJoin('customers as cu', 'cu.id', '=', 'd.customer_id')
|
|
->whereIn('d.id', $currentIds)
|
|
->where('p.is_paid', false)
|
|
->where('p.attempts', '>', 0)
|
|
->whereNull('p.deleted_at')
|
|
->orderByDesc('p.updated_at')
|
|
->limit(15)
|
|
->get([
|
|
'p.id as payment_id',
|
|
'p.amount',
|
|
'p.expected_at',
|
|
'p.attempts',
|
|
'd.id as donation_id',
|
|
'c.title as campaign',
|
|
DB::raw("CONCAT(cu.first_name, ' ', cu.last_name) as donor_name"),
|
|
'cu.email as donor_email',
|
|
])
|
|
->toArray();
|
|
}
|
|
|
|
public function getUpcomingPayments(): array
|
|
{
|
|
$currentIds = $this->currentSeasonIds();
|
|
if ($currentIds->isEmpty()) return [];
|
|
|
|
return DB::table('scheduled_giving_payments as p')
|
|
->join('scheduled_giving_donations as d', 'd.id', '=', 'p.scheduled_giving_donation_id')
|
|
->join('scheduled_giving_campaigns as c', 'c.id', '=', 'd.scheduled_giving_campaign_id')
|
|
->whereIn('d.id', $currentIds)
|
|
->where('p.is_paid', false)
|
|
->where('p.attempts', 0)
|
|
->where('d.is_active', true)
|
|
->whereNull('p.deleted_at')
|
|
->whereBetween('p.expected_at', [now(), now()->addHours(48)])
|
|
->selectRaw("
|
|
c.title as campaign,
|
|
COUNT(*) as payment_count,
|
|
SUM(p.amount) / 100 as total_amount,
|
|
MIN(p.expected_at) as earliest
|
|
")
|
|
->groupBy('c.title')
|
|
->orderBy('earliest')
|
|
->get()
|
|
->toArray();
|
|
}
|
|
|
|
public function getDataQuality(): array
|
|
{
|
|
$total = DB::table('scheduled_giving_donations')->count();
|
|
$softDeleted = DB::table('scheduled_giving_donations')->whereNotNull('deleted_at')->count();
|
|
$noCustomer = DB::table('scheduled_giving_donations')->whereNull('customer_id')->whereNull('deleted_at')->count();
|
|
$noPayments = DB::table('scheduled_giving_donations as d')
|
|
->whereNull('d.deleted_at')
|
|
->whereNotNull('d.customer_id')
|
|
->whereNotExists(function ($q) {
|
|
$q->select(DB::raw(1))
|
|
->from('scheduled_giving_payments as p')
|
|
->whereColumn('p.scheduled_giving_donation_id', 'd.id')
|
|
->whereNull('p.deleted_at');
|
|
})->count();
|
|
$zeroAmount = DB::table('scheduled_giving_donations')
|
|
->whereNull('deleted_at')
|
|
->where('total_amount', '<=', 0)
|
|
->count();
|
|
|
|
return [
|
|
'total_records' => $total,
|
|
'soft_deleted' => $softDeleted,
|
|
'no_customer' => $noCustomer,
|
|
'no_payments' => $noPayments,
|
|
'zero_amount' => $zeroAmount,
|
|
];
|
|
}
|
|
|
|
private function emptyCampaign($c): array
|
|
{
|
|
return [
|
|
'campaign' => $c,
|
|
'all_time_subscribers' => 0, 'all_time_collected' => 0, 'all_time_payments' => 0, 'all_time_paid' => 0,
|
|
'current_subscribers' => 0, 'expired_subscribers' => 0,
|
|
'current_payments' => 0, 'current_paid' => 0, 'current_pending' => 0, 'current_failed' => 0,
|
|
'current_collected' => 0, 'current_pending_amount' => 0, 'avg_per_night' => 0,
|
|
'fully_completed' => 0, 'dates' => $c->dates ?? [], 'total_nights' => count($c->dates ?? []),
|
|
'next_payment' => null,
|
|
];
|
|
}
|
|
}
|