feat: FundAccountingHelper — GAAP fund accounting #19

Merged
jmiller merged 4 commits from dev into main 2026-06-20 22:29:12 +00:00
4 changed files with 142 additions and 3 deletions
+1 -1
View File
@@ -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"
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>01.02.00</version>
<version>01.02.02</version>
<php_minimum>8.3</php_minimum>
<description>MokoSuite NPO component</description>
<namespace path="src">Moko\Component\MokoSuiteNpo</namespace>
@@ -0,0 +1,139 @@
<?php
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Fund accounting — restricted vs unrestricted funds, fund balances, GAAP compliance.
*/
class FundAccountingHelper
{
/**
* Get fund balances summary.
*/
public static function getFundBalances(): array
{
$db = Factory::getContainer()->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,
];
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuite NPO</name>
<packagename>mokosuitenpo</packagename>
<version>01.02.00</version>
<version>01.02.02</version>
<creationDate>2026-06-11</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>