diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml
index c2b02a6..d87ce9e 100644
--- a/.mokogitea/workflows/issue-branch.yml
+++ b/.mokogitea/workflows/issue-branch.yml
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
-# VERSION: 01.00.00
+# VERSION: 01.02.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 cb571de..9c8cd31 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.02.00
+ 01.02.02
8.3
MokoSuite NPO component
Moko\Component\MokoSuiteNpo
diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/FundAccountingHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/FundAccountingHelper.php
new file mode 100644
index 0000000..6f29510
--- /dev/null
+++ b/source/packages/plg_system_mokosuitenpo/src/Helper/FundAccountingHelper.php
@@ -0,0 +1,139 @@
+get(DatabaseInterface::class);
+
+ $db->setQuery($db->getQuery(true)
+ ->select('f.id, f.name, f.type, f.description')
+ ->select('COALESCE((SELECT SUM(d.amount) FROM #__mokosuitenpo_donations d WHERE d.fund_id = f.id), 0) AS total_received')
+ ->select('COALESCE((SELECT SUM(e.amount) FROM #__mokosuitenpo_fund_expenses e WHERE e.fund_id = f.id), 0) AS total_spent')
+ ->from($db->quoteName('#__mokosuitenpo_funds', 'f'))
+ ->where($db->quoteName('f.status') . ' = ' . $db->quote('active'))
+ ->order('f.type ASC, f.name ASC'));
+
+ $funds = $db->loadObjectList() ?: [];
+
+ foreach ($funds as &$f) {
+ $f->balance = round((float) $f->total_received - (float) $f->total_spent, 2);
+ $f->is_restricted = ($f->type === 'restricted' || $f->type === 'temporarily_restricted');
+ }
+
+ return $funds;
+ }
+
+ /**
+ * Record an expense against a fund.
+ */
+ public static function recordExpense(int $fundId, float $amount, string $description, string $category = 'program'): int
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+ $now = Factory::getDate()->toSql();
+
+ // Verify fund exists and has sufficient balance
+ $db->setQuery($db->getQuery(true)->select('type')->from('#__mokosuitenpo_funds')->where('id = ' . (int) $fundId));
+ $fundType = $db->loadResult();
+
+ if (!$fundType) throw new \RuntimeException('Fund not found');
+
+ // Enforce balance check on restricted funds (GAAP compliance)
+ if ($fundType === 'restricted' || $fundType === 'temporarily_restricted') {
+ $db->setQuery($db->getQuery(true)
+ ->select('COALESCE((SELECT SUM(d.amount) FROM #__mokosuitenpo_donations d WHERE d.fund_id = ' . (int) $fundId . '), 0)'
+ . ' - COALESCE((SELECT SUM(e.amount) FROM #__mokosuitenpo_fund_expenses e WHERE e.fund_id = ' . (int) $fundId . '), 0) AS balance')
+ ->from('DUAL'));
+ $balance = (float) $db->loadResult();
+
+ if ($amount > $balance) {
+ throw new \RuntimeException('Insufficient balance in restricted fund (available: $' . number_format($balance, 2) . ', requested: $' . number_format($amount, 2) . ')');
+ }
+ }
+
+ $expense = (object) [
+ 'fund_id' => $fundId,
+ 'amount' => $amount,
+ 'description' => $description,
+ 'category' => $category, // program, admin, fundraising
+ 'recorded_by' => Factory::getApplication()->getIdentity()->id,
+ 'recorded_at' => $now,
+ ];
+
+ $db->insertObject('#__mokosuitenpo_fund_expenses', $expense, 'id');
+ return (int) $expense->id;
+ }
+
+ /**
+ * Get Statement of Financial Position (nonprofit balance sheet).
+ */
+ public static function getFinancialPosition(): object
+ {
+ $funds = self::getFundBalances();
+
+ $unrestricted = 0;
+ $tempRestricted = 0;
+ $permRestricted = 0;
+
+ foreach ($funds as $f) {
+ switch ($f->type) {
+ case 'unrestricted': $unrestricted += $f->balance; break;
+ case 'temporarily_restricted': $tempRestricted += $f->balance; break;
+ case 'permanently_restricted': case 'restricted': $permRestricted += $f->balance; break;
+ }
+ }
+
+ return (object) [
+ 'unrestricted' => $unrestricted,
+ 'temporarily_restricted' => $tempRestricted,
+ 'permanently_restricted' => $permRestricted,
+ 'total_net_assets' => $unrestricted + $tempRestricted + $permRestricted,
+ 'fund_count' => count($funds),
+ ];
+ }
+
+ /**
+ * Get expense breakdown by category (program vs admin vs fundraising).
+ */
+ public static function getExpenseBreakdown(string $from = '', string $to = ''): object
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+ $from = $from ?: date('Y-01-01');
+ $to = $to ?: date('Y-12-31');
+
+ $db->setQuery($db->getQuery(true)
+ ->select('category')
+ ->select('COUNT(*) AS count, COALESCE(SUM(amount), 0) AS total')
+ ->from('#__mokosuitenpo_fund_expenses')
+ ->where('DATE(recorded_at) BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to))
+ ->group('category'));
+
+ $byCategory = $db->loadObjectList('category') ?: [];
+
+ $program = (float) ($byCategory['program']->total ?? 0);
+ $admin = (float) ($byCategory['admin']->total ?? 0);
+ $fundraising = (float) ($byCategory['fundraising']->total ?? 0);
+ $totalExpenses = $program + $admin + $fundraising;
+
+ return (object) [
+ 'program' => $program,
+ 'admin' => $admin,
+ 'fundraising' => $fundraising,
+ 'total' => $totalExpenses,
+ 'program_pct' => $totalExpenses > 0 ? round($program / $totalExpenses * 100, 1) : 0,
+ 'overhead_pct' => $totalExpenses > 0 ? round(($admin + $fundraising) / $totalExpenses * 100, 1) : 0,
+ ];
+ }
+}
diff --git a/source/pkg_mokosuitenpo.xml b/source/pkg_mokosuitenpo.xml
index ad69e32..77ac8cc 100644
--- a/source/pkg_mokosuitenpo.xml
+++ b/source/pkg_mokosuitenpo.xml
@@ -2,7 +2,7 @@
Package - MokoSuite NPO
mokosuitenpo
- 01.02.00
+ 01.02.02
2026-06-11
Moko Consulting
hello@mokoconsulting.tech