feat: webservices plugin, task scheduler, router, config, access

NpoAutomation task plugin: year-end tax receipts, lapsed donor detection,
grant deadline reminders, pledge follow-up emails.
MokoSuiteNpoApi webservices: 6 CRUD routes (donors, donations, campaigns,
grants, volunteers, events). Router, config.xml, access.xml, updates.xml.
This commit is contained in:
Jonathan Miller
2026-06-13 07:10:16 -05:00
parent 675fff8ca6
commit 2b6d52eeb3
5 changed files with 205 additions and 0 deletions
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<access component="com_mokosuitenpo">
<section name="component">
<action name="core.admin" title="JACTION_ADMIN" />
<action name="core.manage" title="JACTION_MANAGE" />
<action name="core.create" title="JACTION_CREATE" />
<action name="core.edit" title="JACTION_EDIT" />
<action name="npo.donations" title="Manage Donations" />
<action name="npo.grants" title="Manage Grants" />
</section>
</access>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<config>
<fieldset name="basic" label="NPO Settings">
<field name="org_name" type="text" default="" label="Organization Name" />
<field name="org_ein" type="text" default="" label="EIN / Tax ID" />
<field name="fiscal_year_start" type="text" default="01-01" label="Fiscal Year Start (MM-DD)" />
</fieldset>
<fieldset name="donations" label="Donations">
<field name="auto_receipt" type="radio" default="1" label="Auto Tax Receipts" class="btn-group btn-group-yesno"><option value="1">JYES</option><option value="0">JNO</option></field>
<field name="receipt_prefix" type="text" default="RCP" label="Receipt Prefix" />
<field name="min_receipt_amount" type="number" default="250" label="Min Receipt Amount ($)" />
</fieldset>
</config>
@@ -0,0 +1,21 @@
<?php
namespace Moko\Component\MokoSuiteNpo\Site\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Component\Router\RouterBase;
class Router extends RouterBase
{
public function build(&$query): array
{
$segments = [];
if (isset($query['view'])) { $segments[] = $query['view']; unset($query['view']); }
if (isset($query['id'])) { $segments[] = $query['id']; unset($query['id']); }
return $segments;
}
public function parse(&$segments): array
{
$vars = [];
if (!empty($segments[0])) $vars['view'] = array_shift($segments);
if (!empty($segments[0]) && is_numeric($segments[0])) $vars['id'] = (int) array_shift($segments);
return $vars;
}
}
@@ -0,0 +1,133 @@
<?php
namespace Moko\Plugin\Task\MokoSuiteNpo\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
use Joomla\Component\Scheduler\Administrator\Task\Status;
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
use Joomla\Database\DatabaseInterface;
use Joomla\Event\SubscriberInterface;
/**
* NPO scheduled tasks: tax receipts, donor lapse detection, grant reminders, pledge follow-up.
*/
class NpoAutomation extends CMSPlugin implements SubscriberInterface
{
use TaskPluginTrait;
protected const TASKS_MAP = [
'mokosuite.npo.receipts.yearend' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITENPO_RECEIPTS_YEAREND',
'method' => 'generateYearEndReceipts',
],
'mokosuite.npo.donors.lapsed' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITENPO_DONORS_LAPSED',
'method' => 'detectLapsedDonors',
],
'mokosuite.npo.grants.reminders' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITENPO_GRANTS_REMINDERS',
'method' => 'sendGrantReminders',
],
'mokosuite.npo.pledges.followup' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITENPO_PLEDGES_FOLLOWUP',
'method' => 'followUpPledges',
],
];
public static function getSubscribedEvents(): array
{
return [
'onExecuteTask' => 'standardRoutineHandler',
'onContentPrepareForm' => 'enhanceTaskItemForm',
'onTaskOptionsList' => 'advertiseRoutines',
];
}
private function generateYearEndReceipts(ExecuteTaskEvent $event): int
{
$count = \Moko\Plugin\System\MokoSuiteNpo\Helper\TaxReceiptHelper::generateYearEndReceipts((int) date('Y') - 1);
Log::add("NPO year-end receipts: generated {$count}", Log::INFO, 'mokosuite.npo');
return Status::OK;
}
private function detectLapsedDonors(ExecuteTaskEvent $event): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->update('#__mokosuitenpo_donors')
->set($db->quoteName('donor_level') . ' = ' . $db->quote('lapsed'))
->where($db->quoteName('last_gift_date') . ' < DATE_SUB(NOW(), INTERVAL 12 MONTH)')
->where($db->quoteName('gift_count') . ' > 0')
->where($db->quoteName('donor_level') . ' != ' . $db->quote('lapsed')));
$db->execute();
$lapsed = $db->getAffectedRows();
if ($lapsed > 0) {
Log::add("NPO lapsed donors: {$lapsed} marked as lapsed", Log::INFO, 'mokosuite.npo');
}
return Status::OK;
}
private function sendGrantReminders(ExecuteTaskEvent $event): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('*')
->from('#__mokosuitenpo_grants')
->where($db->quoteName('status') . ' IN (' . $db->quote('prospect') . ',' . $db->quote('writing') . ')')
->where($db->quoteName('application_deadline') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 14 DAY)'));
$grants = $db->loadObjectList() ?: [];
if (!empty($grants)) {
$body = "Grant deadlines approaching:\n\n";
foreach ($grants as $g) {
$days = max(0, round((strtotime($g->application_deadline) - time()) / 86400));
$body .= "- {$g->title} ({$g->funder_name}) — {$days} days left\n";
}
$mailer = Factory::getMailer();
$mailer->addRecipient(Factory::getApplication()->get('mailfrom'));
$mailer->setSubject('NPO: ' . count($grants) . ' grant deadlines approaching');
$mailer->setBody($body);
$mailer->Send();
}
return Status::OK;
}
private function followUpPledges(ExecuteTaskEvent $event): int
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('p.*, cd.name AS donor_name, cd.email_to')
->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($db->quoteName('p.due_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 14 DAY)'));
$pledges = $db->loadObjectList() ?: [];
$sent = 0;
foreach ($pledges as $p) {
if (!$p->email_to) continue;
$remaining = (float) $p->total_amount - (float) $p->amount_fulfilled;
$mailer = Factory::getMailer();
$mailer->addRecipient($p->email_to, $p->donor_name);
$mailer->setSubject('Pledge reminder — $' . number_format($remaining, 2) . ' remaining');
$mailer->setBody("Hi {$p->donor_name},\n\nThis is a friendly reminder about your pledge of \${$p->total_amount}. Your remaining balance is \${$remaining}.\n\nThank you for your generosity!");
$mailer->Send();
$sent++;
}
Log::add("NPO pledge follow-up: sent {$sent} reminders", Log::INFO, 'mokosuite.npo');
return Status::OK;
}
}
@@ -0,0 +1,27 @@
<?php
namespace Moko\Plugin\WebServices\MokoSuiteNpo\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Event\Application\BeforeApiRouteEvent;
use Joomla\Event\SubscriberInterface;
final class MokoSuiteNpoApi extends CMSPlugin implements SubscriberInterface
{
public static function getSubscribedEvents(): array
{
return ['onBeforeApiRoute' => 'onBeforeApiRoute'];
}
public function onBeforeApiRoute(BeforeApiRouteEvent $event): void
{
$router = $event->getRouter();
$router->createCRUDRoutes('v1/mokosuite/npo/donors', 'npodonors', ['component' => 'com_mokosuitenpo']);
$router->createCRUDRoutes('v1/mokosuite/npo/donations', 'npodonations', ['component' => 'com_mokosuitenpo']);
$router->createCRUDRoutes('v1/mokosuite/npo/campaigns', 'npocampaigns', ['component' => 'com_mokosuitenpo']);
$router->createCRUDRoutes('v1/mokosuite/npo/grants', 'npogrants', ['component' => 'com_mokosuitenpo']);
$router->createCRUDRoutes('v1/mokosuite/npo/volunteers', 'npovolunteers', ['component' => 'com_mokosuitenpo']);
$router->createCRUDRoutes('v1/mokosuite/npo/events', 'npoevents', ['component' => 'com_mokosuitenpo']);
}
}