feat: ImpactReportHelper #16

Merged
jmiller merged 1 commits from dev into main 2026-06-20 18:52:20 +00:00
@@ -0,0 +1,127 @@
<?php
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Impact reporting — donor impact statements, program metrics, annual report data.
*/
class ImpactReportHelper
{
/**
* Generate annual impact summary for a fiscal year.
*/
public static function getAnnualSummary(int $year): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$from = $year . '-01-01';
$to = $year . '-12-31';
// Fundraising
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS donation_count, COALESCE(SUM(amount), 0) AS total_raised, COUNT(DISTINCT donor_id) AS unique_donors')
->from('#__mokosuitenpo_donations')
->where('donation_date BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to)));
$fundraising = $db->loadObject();
// Campaigns
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS campaigns_run')
->select('SUM(CASE WHEN COALESCE((SELECT SUM(d.amount) FROM #__mokosuitenpo_donations d WHERE d.campaign_id = c.id), 0) >= c.goal THEN 1 ELSE 0 END) AS goals_met')
->from($db->quoteName('#__mokosuitenpo_campaigns', 'c'))
->where('c.start_date BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to)));
$campaigns = $db->loadObject();
// Volunteer hours
$db->setQuery($db->getQuery(true)
->select('COUNT(DISTINCT volunteer_id) AS active_volunteers, COALESCE(SUM(hours), 0) AS total_hours')
->from('#__mokosuitenpo_volunteer_hours')
->where('date BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to)));
$volunteers = $db->loadObject();
// Events
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS events_held')
->select('(SELECT COUNT(*) FROM #__mokosuitenpo_event_registrations er JOIN #__mokosuitenpo_events e ON e.id = er.event_id WHERE e.event_date BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to) . ') AS total_attendees')
->from('#__mokosuitenpo_events')
->where('event_date BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to)));
$events = $db->loadObject();
// Grants
$db->setQuery($db->getQuery(true)
->select('COUNT(*) AS grants_received, COALESCE(SUM(amount), 0) AS grant_total')
->from('#__mokosuitenpo_grants')
->where($db->quoteName('status') . ' IN (' . $db->quote('awarded') . ',' . $db->quote('active') . ')')
->where('YEAR(awarded_date) = ' . (int) $year));
$grants = $db->loadObject();
// Donor retention
$priorYearDonors = 0;
$retainedDonors = 0;
try {
$db->setQuery($db->getQuery(true)
->select('COUNT(DISTINCT donor_id)')
->from('#__mokosuitenpo_donations')
->where('YEAR(donation_date) = ' . ($year - 1)));
$priorYearDonors = (int) $db->loadResult();
$db->setQuery($db->getQuery(true)
->select('COUNT(DISTINCT d1.donor_id)')
->from($db->quoteName('#__mokosuitenpo_donations', 'd1'))
->where('YEAR(d1.donation_date) = ' . (int) $year)
->where('d1.donor_id IN (SELECT DISTINCT d2.donor_id FROM #__mokosuitenpo_donations d2 WHERE YEAR(d2.donation_date) = ' . ($year - 1) . ')'));
$retainedDonors = (int) $db->loadResult();
} catch (\Throwable $e) {}
$retentionRate = $priorYearDonors > 0 ? round($retainedDonors / $priorYearDonors * 100, 1) : 0;
return (object) [
'year' => $year,
'total_raised' => (float) $fundraising->total_raised,
'donation_count' => (int) $fundraising->donation_count,
'unique_donors' => (int) $fundraising->unique_donors,
'donor_retention' => $retentionRate,
'campaigns_run' => (int) ($campaigns->campaigns_run ?? 0),
'goals_met' => (int) ($campaigns->goals_met ?? 0),
'volunteer_count' => (int) ($volunteers->active_volunteers ?? 0),
'volunteer_hours' => (float) ($volunteers->total_hours ?? 0),
'events_held' => (int) ($events->events_held ?? 0),
'event_attendees' => (int) ($events->total_attendees ?? 0),
'grants_received' => (int) ($grants->grants_received ?? 0),
'grant_total' => (float) ($grants->grant_total ?? 0),
];
}
/**
* Generate donor-specific impact statement.
*/
public static function getDonorImpact(int $donorId, int $year): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('COALESCE(SUM(amount), 0) AS given_this_year, COUNT(*) AS gift_count')
->from('#__mokosuitenpo_donations')
->where('donor_id = ' . (int) $donorId)
->where('YEAR(donation_date) = ' . (int) $year));
$giving = $db->loadObject();
$db->setQuery($db->getQuery(true)
->select('lifetime_giving, first_gift_date, gift_count AS total_gifts')
->from('#__mokosuitenpo_donors')
->where('id = ' . (int) $donorId));
$donor = $db->loadObject();
return (object) [
'year' => $year,
'given_this_year' => (float) ($giving->given_this_year ?? 0),
'gifts_this_year' => (int) ($giving->gift_count ?? 0),
'lifetime_giving' => (float) ($donor->lifetime_giving ?? 0),
'total_gifts' => (int) ($donor->total_gifts ?? 0),
'member_since' => $donor->first_gift_date ?? null,
];
}
}