feat: DonorRetentionHelper — LYBUNT/SYBUNT, retention rates #21

Merged
jmiller merged 3 commits from dev into main 2026-06-21 14:05:16 +00:00
4 changed files with 123 additions and 3 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.00.00
# VERSION: 01.04.02
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>01.04.00</version>
<version>01.04.02</version>
<php_minimum>8.3</php_minimum>
<description>MokoSuite NPO component</description>
<namespace path="src">Moko\Component\MokoSuiteNpo</namespace>
@@ -0,0 +1,120 @@
<?php
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Donor retention analysis — LYBUNT/SYBUNT detection, retention rates, lapsed outreach lists.
*/
class DonorRetentionHelper
{
/**
* Get LYBUNT donors — gave Last Year But Unfortunately Not This year.
*/
public static function getLybunt(int $currentYear = 0): array
{
$currentYear = $currentYear ?: (int) date('Y');
$lastYear = $currentYear - 1;
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('cd.id AS contact_id, cd.name, cd.email_to, cd.telephone')
->select('MAX(d.donation_date) AS last_donation_date')
->select('SUM(CASE WHEN YEAR(d.donation_date) = ' . $lastYear . ' THEN d.amount ELSE 0 END) AS last_year_total')
->from($db->quoteName('#__contact_details', 'cd'))
->join('INNER', $db->quoteName('#__mokosuitenpo_donations', 'd') . ' ON d.contact_id = cd.id')
->where('YEAR(d.donation_date) = ' . $lastYear)
->where('cd.id NOT IN (SELECT d2.contact_id FROM #__mokosuitenpo_donations d2 WHERE YEAR(d2.donation_date) = ' . $currentYear . ')')
->group('cd.id')
->order('last_year_total DESC'));
return $db->loadObjectList() ?: [];
}
/**
* Get SYBUNT donors — gave Some Year But Unfortunately Not This year.
*/
public static function getSybunt(int $currentYear = 0, int $lookbackYears = 3): array
{
$currentYear = $currentYear ?: (int) date('Y');
$lastYear = $currentYear - 1;
$startYear = $currentYear - $lookbackYears;
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('cd.id AS contact_id, cd.name, cd.email_to')
->select('COUNT(DISTINCT YEAR(d.donation_date)) AS years_donated')
->select('MAX(d.donation_date) AS last_donation_date')
->select('SUM(d.amount) AS lifetime_total')
->from($db->quoteName('#__contact_details', 'cd'))
->join('INNER', $db->quoteName('#__mokosuitenpo_donations', 'd') . ' ON d.contact_id = cd.id')
->where('YEAR(d.donation_date) BETWEEN ' . $startYear . ' AND ' . $lastYear)
->where('cd.id NOT IN (SELECT d2.contact_id FROM #__mokosuitenpo_donations d2 WHERE YEAR(d2.donation_date) = ' . $currentYear . ')')
->group('cd.id')
->order('lifetime_total DESC'));
return $db->loadObjectList() ?: [];
}
/**
* Calculate retention rate — percentage of donors who gave again this year.
*/
public static function getRetentionRate(int $currentYear = 0): object
{
$currentYear = $currentYear ?: (int) date('Y');
$lastYear = $currentYear - 1;
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('COUNT(DISTINCT d.contact_id) AS last_year_donors')
->from($db->quoteName('#__mokosuitenpo_donations', 'd'))
->where('YEAR(d.donation_date) = ' . $lastYear));
$lastYearDonors = (int) $db->loadResult();
$db->setQuery($db->getQuery(true)
->select('COUNT(DISTINCT d.contact_id) AS retained')
->from($db->quoteName('#__mokosuitenpo_donations', 'd'))
->where('YEAR(d.donation_date) = ' . $currentYear)
->where('d.contact_id IN (SELECT d2.contact_id FROM #__mokosuitenpo_donations d2 WHERE YEAR(d2.donation_date) = ' . $lastYear . ')'));
$retained = (int) $db->loadResult();
return (object) [
'last_year_donors' => $lastYearDonors,
'retained' => $retained,
'lapsed' => $lastYearDonors - $retained,
'retention_rate' => $lastYearDonors > 0 ? round($retained / $lastYearDonors * 100, 1) : 0,
];
}
/**
* Get donor giving trends — year-over-year comparison.
*/
public static function getGivingTrends(int $years = 5): array
{
$currentYear = (int) date('Y');
$startYear = $currentYear - $years + 1;
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('YEAR(donation_date) AS year')
->select('COUNT(*) AS donation_count')
->select('COUNT(DISTINCT contact_id) AS unique_donors')
->select('SUM(amount) AS total_amount')
->select('AVG(amount) AS avg_donation')
->from('#__mokosuitenpo_donations')
->where('YEAR(donation_date) BETWEEN ' . $startYear . ' AND ' . $currentYear)
->group('YEAR(donation_date)')
->order('year ASC'));
$trends = $db->loadObjectList() ?: [];
foreach ($trends as &$t) {
$t->avg_donation = round((float) $t->avg_donation, 2);
}
return $trends;
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuite NPO</name>
<packagename>mokosuitenpo</packagename>
<version>01.04.00</version>
<version>01.04.02</version>
<creationDate>2026-06-11</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>