diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml
index 75a6963..53d2e35 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.03.08
# 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 a473900..efdf2d9 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.03.00
+ 01.03.08
8.3
MokoSuite NPO component
Moko\Component\MokoSuiteNpo
diff --git a/source/packages/com_mokosuitenpo/site/src/View/ThankYou/HtmlView.php b/source/packages/com_mokosuitenpo/site/src/View/ThankYou/HtmlView.php
new file mode 100644
index 0000000..38af310
--- /dev/null
+++ b/source/packages/com_mokosuitenpo/site/src/View/ThankYou/HtmlView.php
@@ -0,0 +1,52 @@
+get(DatabaseInterface::class);
+ $donationId = Factory::getApplication()->getInput()->getInt('id', 0);
+ $token = Factory::getApplication()->getInput()->getString('token', '');
+
+ if (!$donationId || !$token) {
+ parent::display($tpl);
+ return;
+ }
+
+ // Verify token matches donation (prevents enumeration)
+ $db->setQuery($db->getQuery(true)
+ ->select('d.*, cd.name AS donor_name, f.name AS fund_name, c.title AS campaign_title')
+ ->from($db->quoteName('#__mokosuitenpo_donations', 'd'))
+ ->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = d.donor_id')
+ ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id')
+ ->join('LEFT', $db->quoteName('#__mokosuitenpo_funds', 'f') . ' ON f.id = d.fund_id')
+ ->join('LEFT', $db->quoteName('#__mokosuitenpo_campaigns', 'c') . ' ON c.id = d.campaign_id')
+ ->where('d.id = ' . (int) $donationId)
+ ->where($db->quoteName('d.confirmation_token') . ' = ' . $db->quote($token)));
+ $this->donation = $db->loadObject();
+
+ if ($this->donation) {
+ // Get tax receipt if generated
+ $db->setQuery($db->getQuery(true)
+ ->select('receipt_number, issued_date, amount')
+ ->from('#__mokosuitenpo_tax_receipts')
+ ->where('donation_id = ' . (int) $donationId));
+ $this->receipt = $db->loadObject();
+ }
+
+ parent::display($tpl);
+ }
+}
diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/GrantReportingHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/GrantReportingHelper.php
new file mode 100644
index 0000000..81c7a89
--- /dev/null
+++ b/source/packages/plg_system_mokosuitenpo/src/Helper/GrantReportingHelper.php
@@ -0,0 +1,65 @@
+get(DatabaseInterface::class);
+
+ $db->setQuery($db->getQuery(true)
+ ->select('g.*, cd.name AS funder_name')
+ ->from($db->quoteName('#__mokosuitenpo_grants', 'g'))
+ ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = g.funder_contact_id')
+ ->where('g.id = ' . (int) $grantId));
+ $grant = $db->loadObject();
+
+ if (!$grant) return (object) ['found' => false];
+
+ // Get expenses charged to this grant's fund
+ $db->setQuery($db->getQuery(true)
+ ->select('category, COUNT(*) AS count, COALESCE(SUM(amount), 0) AS spent')
+ ->from('#__mokosuitenpo_fund_expenses')
+ ->where('fund_id = ' . (int) ($grant->fund_id ?? 0))
+ ->group('category')
+ ->order('spent DESC'));
+ $grant->spending_by_category = $db->loadObjectList() ?: [];
+
+ $grant->total_spent = array_sum(array_column($grant->spending_by_category, 'spent'));
+ $grant->remaining = max(0, (float) ($grant->amount ?? 0) - (float) $grant->total_spent);
+ $grant->utilization_pct = (float) ($grant->amount ?? 0) > 0
+ ? round((float) $grant->total_spent / (float) $grant->amount * 100, 1) : 0;
+
+ return $grant;
+ }
+
+ /**
+ * Get grants requiring reports soon.
+ */
+ public static function getUpcomingReportDeadlines(int $days = 30): array
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $db->setQuery($db->getQuery(true)
+ ->select('g.id, g.title, g.funder, g.report_due_date, g.amount')
+ ->select('DATEDIFF(g.report_due_date, CURDATE()) AS days_until_due')
+ ->from($db->quoteName('#__mokosuitenpo_grants', 'g'))
+ ->where($db->quoteName('g.status') . ' IN (' . $db->quote('active') . ',' . $db->quote('reporting') . ')')
+ ->where($db->quoteName('g.report_due_date') . ' IS NOT NULL')
+ ->where($db->quoteName('g.report_due_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . (int) $days . ' DAY)')
+ ->order('g.report_due_date ASC'));
+
+ return $db->loadObjectList() ?: [];
+ }
+}
diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/PledgeReminderHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/PledgeReminderHelper.php
new file mode 100644
index 0000000..0cadc19
--- /dev/null
+++ b/source/packages/plg_system_mokosuitenpo/src/Helper/PledgeReminderHelper.php
@@ -0,0 +1,64 @@
+get(DatabaseInterface::class);
+
+ $db->setQuery($db->getQuery(true)
+ ->select('p.*, cd.name AS donor_name, cd.email_to')
+ ->select('COALESCE((SELECT SUM(d.amount) FROM #__mokosuitenpo_donations d WHERE d.pledge_id = p.id), 0) AS amount_fulfilled')
+ ->from($db->quoteName('#__mokosuitenpo_pledges', 'p'))
+ ->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = p.donor_id')
+ ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id')
+ ->where($db->quoteName('p.status') . ' = ' . $db->quote('active'))
+ ->where('p.due_date IS NOT NULL')
+ ->where('p.due_date < DATE_SUB(CURDATE(), INTERVAL ' . (int) $overdueDays . ' DAY)')
+ ->having('amount_fulfilled < p.amount')
+ ->order('p.due_date ASC'));
+
+ $pledges = $db->loadObjectList() ?: [];
+
+ foreach ($pledges as &$p) {
+ $p->remaining = round((float) $p->amount - (float) $p->amount_fulfilled, 2);
+ $p->fulfillment_pct = (float) $p->amount > 0 ? round((float) $p->amount_fulfilled / (float) $p->amount * 100, 1) : 0;
+ }
+
+ return $pledges;
+ }
+
+ /**
+ * Get pledge fulfillment summary.
+ */
+ public static function getFulfillmentSummary(): object
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $db->setQuery($db->getQuery(true)
+ ->select('COUNT(*) AS total_pledges')
+ ->select('COALESCE(SUM(amount), 0) AS total_pledged')
+ ->select('COALESCE(SUM((SELECT COALESCE(SUM(d.amount), 0) FROM #__mokosuitenpo_donations d WHERE d.pledge_id = p.id)), 0) AS total_received')
+ ->from($db->quoteName('#__mokosuitenpo_pledges', 'p'))
+ ->where($db->quoteName('p.status') . ' IN (' . $db->quote('active') . ',' . $db->quote('completed') . ')'));
+
+ $stats = $db->loadObject() ?: (object) ['total_pledges' => 0, 'total_pledged' => 0, 'total_received' => 0];
+ $stats->outstanding = round((float) $stats->total_pledged - (float) $stats->total_received, 2);
+ $stats->fulfillment_rate = (float) $stats->total_pledged > 0
+ ? round((float) $stats->total_received / (float) $stats->total_pledged * 100, 1) : 0;
+
+ return $stats;
+ }
+}
diff --git a/source/pkg_mokosuitenpo.xml b/source/pkg_mokosuitenpo.xml
index 0274c16..a2ed576 100644
--- a/source/pkg_mokosuitenpo.xml
+++ b/source/pkg_mokosuitenpo.xml
@@ -2,7 +2,7 @@
Package - MokoSuite NPO
mokosuitenpo
- 01.03.00
+ 01.03.08
2026-06-11
Moko Consulting
hello@mokoconsulting.tech