diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml
index 75a6963..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.00.00
+# 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 a9d716e..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.00
+ 01.04.02
8.3
MokoSuite NPO component
Moko\Component\MokoSuiteNpo
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;
+ }
+}
diff --git a/source/pkg_mokosuitenpo.xml b/source/pkg_mokosuitenpo.xml
index 1d740f9..1928507 100644
--- a/source/pkg_mokosuitenpo.xml
+++ b/source/pkg_mokosuitenpo.xml
@@ -2,7 +2,7 @@
Package - MokoSuite NPO
mokosuitenpo
- 01.04.00
+ 01.04.02
2026-06-11
Moko Consulting
hello@mokoconsulting.tech