Files
calvana/temp_files/fix2/ScheduledGivingDashboard.php
Omair Saleh 50d449e2b7 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
2026-03-05 04:19:23 +08:00

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