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