From 4cf53595fdd80085c2dc82f2daac64681ea1cb04 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 21 Jun 2026 08:49:01 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20DonorRetentionHelper=20=E2=80=94=20?= =?UTF-8?q?LYBUNT/SYBUNT=20detection,=20retention=20rates,=20giving=20tren?= =?UTF-8?q?ds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Helper/DonorRetentionHelper.php | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 source/packages/plg_system_mokosuitenpo/src/Helper/DonorRetentionHelper.php diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/DonorRetentionHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/DonorRetentionHelper.php new file mode 100644 index 0000000..6b20186 --- /dev/null +++ b/source/packages/plg_system_mokosuitenpo/src/Helper/DonorRetentionHelper.php @@ -0,0 +1,120 @@ +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; + } +} -- 2.52.0 From 74edae4d4d58b1862f9f22eef3bb6f3e51d20ec8 Mon Sep 17 00:00:00 2001 From: "gitea-actions[bot]" Date: Sun, 21 Jun 2026 13:49:18 +0000 Subject: [PATCH 2/3] chore(version): auto-bump patch 01.04.01-dev [skip ci] --- .mokogitea/workflows/issue-branch.yml | 2 +- source/packages/com_mokosuitenpo/mokosuitenpo.xml | 2 +- source/pkg_mokosuitenpo.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 75a6963..bd707ae 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.00.00 +# VERSION: 01.04.01 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/source/packages/com_mokosuitenpo/mokosuitenpo.xml b/source/packages/com_mokosuitenpo/mokosuitenpo.xml index a9d716e..fb56893 100644 --- a/source/packages/com_mokosuitenpo/mokosuitenpo.xml +++ b/source/packages/com_mokosuitenpo/mokosuitenpo.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 01.04.00 + 01.04.01 8.3 MokoSuite NPO component Moko\Component\MokoSuiteNpo diff --git a/source/pkg_mokosuitenpo.xml b/source/pkg_mokosuitenpo.xml index 1d740f9..3444f85 100644 --- a/source/pkg_mokosuitenpo.xml +++ b/source/pkg_mokosuitenpo.xml @@ -2,7 +2,7 @@ Package - MokoSuite NPO mokosuitenpo - 01.04.00 + 01.04.01 2026-06-11 Moko Consulting hello@mokoconsulting.tech -- 2.52.0 From b27fcdb7ce6bb2be92b06dcba387b7ae0fd6ffbb Mon Sep 17 00:00:00 2001 From: "gitea-actions[bot]" Date: Sun, 21 Jun 2026 13:49:26 +0000 Subject: [PATCH 3/3] chore(version): pre-release bump to 01.04.02-dev [skip ci] --- .mokogitea/workflows/issue-branch.yml | 2 +- source/packages/com_mokosuitenpo/mokosuitenpo.xml | 2 +- source/pkg_mokosuitenpo.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index bd707ae..8038885 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.04.01 +# VERSION: 01.04.02 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/source/packages/com_mokosuitenpo/mokosuitenpo.xml b/source/packages/com_mokosuitenpo/mokosuitenpo.xml index fb56893..17839f0 100644 --- a/source/packages/com_mokosuitenpo/mokosuitenpo.xml +++ b/source/packages/com_mokosuitenpo/mokosuitenpo.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 01.04.01 + 01.04.02 8.3 MokoSuite NPO component Moko\Component\MokoSuiteNpo diff --git a/source/pkg_mokosuitenpo.xml b/source/pkg_mokosuitenpo.xml index 3444f85..1928507 100644 --- a/source/pkg_mokosuitenpo.xml +++ b/source/pkg_mokosuitenpo.xml @@ -2,7 +2,7 @@ Package - MokoSuite NPO mokosuitenpo - 01.04.01 + 01.04.02 2026-06-11 Moko Consulting hello@mokoconsulting.tech -- 2.52.0