|
|
|
@@ -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,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|