feat: PledgeReminderHelper + ThankYou view + incremental #20

Merged
jmiller merged 18 commits from dev into main 2026-06-21 06:33:44 +00:00
6 changed files with 184 additions and 3 deletions
+1 -1
View File
@@ -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"
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>01.03.00</version>
<version>01.03.08</version>
<php_minimum>8.3</php_minimum>
<description>MokoSuite NPO component</description>
<namespace path="src">Moko\Component\MokoSuiteNpo</namespace>
@@ -0,0 +1,52 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Site\View\ThankYou;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\Database\DatabaseInterface;
/**
* Donation thank-you page — displays after successful donation with receipt info.
*/
class HtmlView extends BaseHtmlView
{
public ?object $donation = null;
public ?object $receipt = null;
public function display($tpl = null): void
{
$db = Factory::getContainer()->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);
}
}
@@ -0,0 +1,65 @@
<?php
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Grant reporting — deliverable tracking, spending reports, funder compliance.
*/
class GrantReportingHelper
{
/**
* Get grant spending report — budgeted vs actual by category.
*/
public static function getSpendingReport(int $grantId): object
{
$db = Factory::getContainer()->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() ?: [];
}
}
@@ -0,0 +1,64 @@
<?php
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Pledge reminders — notify donors of unfulfilled pledges, track fulfillment progress.
*/
class PledgeReminderHelper
{
/**
* Get unfulfilled pledges that need reminders.
*/
public static function getUnfulfilled(int $overdueDays = 30): array
{
$db = Factory::getContainer()->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;
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuite NPO</name>
<packagename>mokosuitenpo</packagename>
<version>01.03.00</version>
<version>01.03.08</version>
<creationDate>2026-06-11</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>